В крупных приложениях работа с сервером быстро превращается в источник хаоса:
TanStack Query решает задачи синхронизации серверного состояния, но не заменяет архитектуру сетевого слоя. Именно поэтому в современных проектах формируется отдельный centralized API layer — централизованный слой работы с backend.
Основная идея заключается в разделении ответственности:
| Слой | Ответственность |
|---|---|
| API layer | HTTP, headers, auth, retry, serialization |
| Query layer | caching, refetching, invalidation |
| UI layer | rendering |
| Domain layer | бизнес-логика |
Такое разделение особенно важно при масштабировании проекта.
На ранних этапах разработки часто встречается следующий подход:
useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Request failed');
}
return response.json();
}
});
Подобный код кажется простым, но со временем возникают проблемы:
Во множестве запросов повторяется:
Компонент начинает знать:
UI постепенно превращается в транспортный слой.
Компоненты с прямыми fetch-вызовами тяжелее тестировать:
render(<UsersPage />);
Тесту приходится мокать:
При централизованном API достаточно замокать сервис.
Переход:
превращается в массовый рефакторинг.
Типичная структура:
src/
├── api/
│ ├── client/
│ │ ├── httpClient.ts
│ │ ├── auth.ts
│ │ └── interceptors.ts
│ │
│ ├── services/
│ │ ├── users.service.ts
│ │ ├── posts.service.ts
│ │ └── comments.service.ts
│ │
│ ├── dto/
│ ├── mappers/
│ └── errors/
│
├── queries/
├── mutations/
├── hooks/
└── components/
Центральная точка взаимодействия с сетью обычно представлена единым клиентом.
const API_URL = 'https://api.example.com';
export async function http<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(`${API_URL}${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...options?.headers
},
credentials: 'include',
...options
});
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return response.json();
}
Теперь все запросы используют единый механизм.
Generic-параметры особенно важны в TypeScript-проектах.
interface User {
id: number;
name: string;
}
const users = await http<User[]>('/users');
Преимущества:
Следующий уровень абстракции — сервисы.
import { http } fr om '../client/httpClient';
export const usersService = {
getAll() {
return http<User[]>('/users');
},
getById(id: number) {
return http<User>(`/users/${id}`);
},
create(data: CreateUserDto) {
return http<User>('/users', {
method: 'POST',
body: JSON.stringify(data)
});
}
};
Теперь компоненты ничего не знают о fetch.
useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('/api/users');
return response.json();
}
});
useQuery({
queryKey: ['users'],
queryFn: usersService.getAll
});
Компонент становится декларативным.
Очень важно понимать:
TanStack Query не должен заменять API-слой.
Ошибка:
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('/users');
return response.json();
}
});
}
В этом случае:
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: usersService.getAll
});
}
Без централизованного слоя:
if (!response.ok) {
throw new Error('Failed');
}
дублируется десятки раз.
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
if (!response.ok) {
throw new ApiError(
'Request failed',
response.status
);
}
Одна из ключевых причин централизованного API-слоя — единая авторизация.
if (response.status === 401) {
logout();
window.location.href = '/login';
}
Теперь logout происходит автоматически для всех запросов.
const token = localStorage.getItem('token');
headers: {
Authorization: `Bearer ${token}`
}
Компоненты не должны знать о токенах.
Многие проекты используют axios из-за interceptors.
import axios from 'axios';
export const api = axios.create({
baseURL: 'https://api.example.com',
withCredentials: true
});
api.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
logout();
}
return Promise.reject(error);
}
);
Серверные модели не всегда должны использоваться напрямую в UI.
interface UserDto {
user_id: number;
full_name: string;
}
interface User {
id: number;
name: string;
}
function mapUser(dto: UserDto): User {
return {
id: dto.user_id,
name: dto.full_name
};
}
Backend может менять:
UI останется стабильным.
UI работает с удобными структурами:
user.name
вместо:
user.full_name
export const usersService = {
async getAll(): Promise<User[]> {
const dto = await http<UserDto[]>('/users');
return dto.map(mapUser);
}
};
Теперь query layer получает уже готовую доменную модель.
API layer и query keys должны проектироваться совместно.
['data']
Непонятно:
['users']
['users', userId]
['posts', postId]
['posts', postId, 'comments']
Крупные проекты выносят ключи в отдельный модуль.
export const queryKeys = {
users: {
all: ['users'] as const,
detail: (id: number) =>
['users', id] as const
}
};
useQuery({
queryKey: queryKeys.users.detail(id),
queryFn: () => usersService.getById(id)
});
Mutations тоже должны использовать centralized API.
useMutation({
mutationFn: async data => {
return fetch('/users', {
method: 'POST',
body: JSON.stringify(data)
});
}
});
useMutation({
mutationFn: usersService.create
});
const queryClient = useQueryClient();
useMutation({
mutationFn: usersService.create,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.users.all
});
}
});
В больших проектах создаются отдельные query hooks.
export function useUsers() {
return useQuery({
queryKey: queryKeys.users.all,
queryFn: usersService.getAll
});
}
Одинаковая логика не дублируется.
staleTime
gcTime
retry
select
enabled
настраиваются в одном месте.
Компонент превращается в декларативный UI:
const { data } = useUsers();
В крупных проектах встречается дополнительный слой:
export const usersQueries = {
all: () => ({
queryKey: queryKeys.users.all,
queryFn: usersService.getAll
})
};
useQuery(usersQueries.all());
queryClient.prefetchQuery(
usersQueries.all()
);
dehydrate(queryClient);
Одинаковая конфигурация используется:
Крупные приложения часто используют несколько backend-сервисов.
auth-api
billing-api
content-api
analytics-api
export const authApi = axios.create({
baseURL: AUTH_API
});
export const billingApi = axios.create({
baseURL: BILLING_API
});
Разные backend могут иметь:
Retry должен быть централизованным.
async function requestWithRetry() {
}
retry: 3
| Уровень | Задача |
|---|---|
| HTTP client | network retry |
| TanStack Query | stale data retry |
TanStack Query умеет отменять запросы.
async function getUsers({
signal
}: QueryFunctionContext) {
return http('/users', {
signal
});
}
Без abort:
Централизованный слой упрощает миграции.
baseURL: '/api/v2'
или:
'/v1/users'
'/v2/users'
Многие современные проекты переходят от технической структуры к feature-based.
features/
├── users/
│ ├── api/
│ ├── hooks/
│ ├── queries/
│ ├── types/
│ └── components/
Все относящееся к users находится рядом.
Feature можно:
При SSR особенно важно отсутствие прямых fetch внутри компонентов.
await queryClient.prefetchQuery(
usersQueries.all()
);
dehydrate(queryClient);
Одинаковые сервисы работают:
Централизованный слой резко упрощает тесты.
vi.mock(usersService);
usersService.getAll.mockResolvedValue([
{
id: 1,
name: 'John'
}
]);
API layer становится единым местом интеграции с backend.
TypeScript проверяет только compile-time.
Backend может вернуть:
null
undefined
wrong shape
const UserSchema = z.object({
id: z.number(),
name: z.string()
});
const parsed = UserSchema.parse(data);
Frontend не ломается из-за неожиданного ответа.
Ошибки обнаруживаются мгновенно.
Проблемы локализуются в API layer.
Очень распространённая ошибка:
useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('/users');
const data = await response.json();
return data
.filter(user => user.active)
.sort((a, b) => a.name.localeCompare(b.name))
.map(transformUser);
}
});
queryFn превращается одновременно в:
| Логика | Место |
|---|---|
| HTTP | API client |
| mapping | mappers |
| filtering | selectors |
| caching | TanStack Query |
| UI | components |
Для преобразований лучше использовать select.
useQuery({
queryKey: ['users'],
queryFn: usersService.getAll,
select: users =>
users.filter(user => user.active)
});
Архитектура выдерживает рост проекта.
Все запросы работают одинаково.
Изменения локализованы.
Ошибки и auth контролируются централизованно.
Компоненты не зависят от транспорта.
Легче мокать и изолировать зависимости.
Component
↓
Custom Hook
↓
Query Factory
↓
Service Layer
↓
HTTP Client
↓
Backend API
src/
├── shared/
│ ├── api/
│ │ ├── client.ts
│ │ ├── errors.ts
│ │ ├── auth.ts
│ │ └── validators.ts
│ │
│ ├── query/
│ │ ├── queryClient.ts
│ │ └── queryKeys.ts
│
├── features/
│ ├── users/
│ │ ├── api/
│ │ │ ├── users.service.ts
│ │ │ ├── users.queries.ts
│ │ │ └── users.mutations.ts
│ │ │
│ │ ├── hooks/
│ │ ├── types/
│ │ ├── mappers/
│ │ └── components/
│ │
│ └── posts/
│
└── app/