При работе с TanStack Query сетевой слой быстро начинает
разрастаться. В простых примерах query-функции часто содержат прямые
вызовы fetch, обработку ошибок и преобразование данных
прямо внутри компонентов. Такой подход допустим для демонстрации
библиотеки, но плохо подходит для крупных приложений.
Абстракции над API позволяют:
fetch, axios,
GraphQL-клиентами и другими транспортами.TanStack Query не является HTTP-клиентом. Библиотека отвечает за:
Получение данных остаётся ответственностью разработчика. Именно поэтому архитектура API-слоя становится критически важной.
Наиболее примитивный вариант выглядит следующим образом:
const fetchUsers = async () => {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Ошибка загрузки');
}
return response.json();
};
const usersQuery = useQuery({
queryKey: ['users'],
queryFn: fetchUsers
});
Проблемы такого подхода:
response.ok дублируется;При росте приложения код превращается в набор разрозненных query-функций с повторяющейся логикой.
Первый уровень абстракции — создание отдельного API-клиента.
export class ApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
async request(url, options = {}) {
const response = await fetch(`${this.baseUrl}${url}`, {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
});
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
return response.json();
}
get(url) {
return this.request(url, {
method: 'GET'
});
}
post(url, body) {
return this.request(url, {
method: 'POST',
body: JSON.stringify(body)
});
}
put(url, body) {
return this.request(url, {
method: 'PUT',
body: JSON.stringify(body)
});
}
delete(url) {
return this.request(url, {
method: 'DELETE'
});
}
}
Использование:
export const api = new ApiClient('/api');
const fetchUsers = () => {
return api.get('/users');
};
В крупных приложениях единый API-клиент быстро становится перегруженным. Обычно используется доменное разделение.
Структура:
src/
├── api/
│ ├── client.ts
│ ├── users.api.ts
│ ├── posts.api.ts
│ ├── auth.api.ts
│ └── comments.api.ts
import { api } from './client';
export const usersApi = {
getUsers() {
return api.get('/users');
},
getUser(id) {
return api.get(`/users/${id}`);
},
createUser(data) {
return api.post('/users', data);
},
updateUser(id, data) {
return api.put(`/users/${id}`, data);
},
deleteUser(id) {
return api.delete(`/users/${id}`);
}
};
Теперь query-функции становятся компактными:
const usersQuery = useQuery({
queryKey: ['users'],
queryFn: usersApi.getUsers
});
Одна из главных задач абстракции — скрыть транспортную реализацию.
Компоненты и query-hooks не должны знать:
fetch или axios;const query = useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await axios.get('/users');
return response.data;
}
});
Компонент напрямую зависит от axios.
export const usersApi = {
async getUsers() {
return api.get('/users');
}
};
Теперь замена транспорта не затрагивает слой TanStack Query.
Часто создаются готовые query options.
export const usersQueries = {
all() {
return {
queryKey: ['users'],
queryFn: usersApi.getUsers
};
},
detail(id) {
return {
queryKey: ['users', id],
queryFn: () => usersApi.getUser(id)
};
}
};
Использование:
const usersQuery = useQuery(usersQueries.all());
const userQuery = useQuery(usersQueries.detail(userId));
Преимущества:
Очень распространённый паттерн.
export const userKeys = {
all: ['users'],
lists: () => [...userKeys.all, 'list'],
list: (filters) => [...userKeys.lists(), filters],
details: () => [...userKeys.all, 'detail'],
detail: (id) => [...userKeys.details(), id]
};
Использование:
useQuery({
queryKey: userKeys.detail(id),
queryFn: () => usersApi.getUser(id)
});
Наиболее удобный подход — единая query factory.
export const usersQueries = {
all: () => ({
queryKey: ['users'],
queryFn: usersApi.getUsers
}),
detail: (id) => ({
queryKey: ['users', id],
queryFn: () => usersApi.getUser(id)
})
};
Такой подход особенно удобен в:
prefetchQuery;ensureQueryData;Mutations также часто выносятся в отдельные factory-функции.
export const usersMutations = {
create() {
return {
mutationFn: usersApi.createUser
};
},
update() {
return {
mutationFn: ({ id, data }) => {
return usersApi.updateUser(id, data);
}
};
}
};
Использование:
const createUserMutation = useMutation(
usersMutations.create()
);
Следующий уровень — создание hooks поверх TanStack Query.
export const useUsers = () => {
return useQuery({
queryKey: ['users'],
queryFn: usersApi.getUsers
});
};
Использование:
const { data, isPending } = useUsers();
Они особенно эффективны при наличии:
Чрезмерная абстракция может ухудшить поддержку.
Плохо:
useUsersData()
useUsersFetcher()
useUsersProvider()
useUsersResource()
useUsersController()
Появляется многослойная архитектура без реальной пользы.
Иногда API-слой отделяется от бизнес-логики.
TanStack Query
↓
Service Layer
↓
API Layer
↓
HTTP Client
export const usersApi = {
getUsers() {
return api.get('/users');
}
};
export const usersService = {
async getActiveUsers() {
const users = await usersApi.getUsers();
return users.filter(user => user.active);
}
};
useQuery({
queryKey: ['active-users'],
queryFn: usersService.getActiveUsers
});
Сервисный слой полезен для:
Одна из важнейших задач абстракции — преобразование серверных данных.
Серверный формат редко идеально подходит UI.
API:
{
"user_id": 15,
"first_name": "John",
"last_name": "Smith",
"is_active": 1
}
UI ожидает:
{
id: number;
fullName: string;
isActive: boolean;
}
const mapUserDto = (dto) => {
return {
id: dto.user_id,
fullName: `${dto.first_name} ${dto.last_name}`,
isActive: Boolean(dto.is_active)
};
};
export const usersApi = {
async getUsers() {
const data = await api.get('/users');
return data.map(mapUserDto);
}
};
Теперь UI полностью изолирован от серверного DTO.
Без абстракции ошибки становятся хаотичными.
if (!response.ok) {
throw new Error('Ошибка');
}
export class ApiError extends Error {
constructor(message, status, payload) {
super(message);
this.status = status;
this.payload = payload;
}
}
async request(url, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
const payload = await response.json();
throw new ApiError(
payload.message,
response.status,
payload
);
}
return response.json();
}
Появляется возможность:
Авторизация не должна дублироваться в query-функциях.
fetch('/users', {
headers: {
Authorization: `Bearer ${token}`
}
});
async request(url, options = {}) {
const token = authStorage.getToken();
return fetch(url, {
...options,
headers: {
Authorization: `Bearer ${token}`,
...options.headers
}
});
}
Крупные приложения часто внедряют автоматическое обновление токенов.
async request(url, options = {}) {
const response = await fetch(url, options);
if (response.status === 401) {
await refreshToken();
return fetch(url, options);
}
return response.json();
}
Такая логика должна жить исключительно внутри API-абстракции.
TanStack Query не зависит от REST.
export const graphqlClient = async (
query,
variables = {}
) => {
const response = await fetch('/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query,
variables
})
});
return response.json();
};
export const usersApi = {
async getUsers() {
const response = await graphqlClient(`
query {
users {
id
name
}
}
`);
return response.data.users;
}
};
Для TanStack Query транспорт остаётся прозрачным.
В современных проектах часто используются генераторы:
export const usersApi = {
getUsers() {
return UsersService.getUsers();
}
};
TanStack Query взаимодействует уже с готовым SDK.
export const usersQueries = {
all: () => ({
queryKey: ['users'],
queryFn: usersApi.getUsers
})
};
При SSR особенно важно иметь централизованные query factories.
await queryClient.prefetchQuery(
usersQueries.all()
);
useQuery(usersQueries.all());
Повторное использование одной конфигурации снижает вероятность ошибок.
Централизация query keys значительно упрощает обновление кэша.
queryClient.invalidateQueries({
queryKey: userKeys.all
});
Иногда invalidation выносится в сервисы.
export const usersCache = {
invalidateAll(queryClient) {
return queryClient.invalidateQueries({
queryKey: ['users']
});
}
};
Одна из мощных возможностей TanStack Query — повторное использование query-конфигурации.
const usersQuery = usersQueries.all();
Использование:
useQuery(usersQuery);
queryClient.prefetchQuery(usersQuery);
queryClient.ensureQueryData(usersQuery);
Чем лучше изолирован API-слой, тем проще тестирование.
vi.mock('./users.api');
test('returns active users', async () => {
usersApi.getUsers.mockResolvedValue([
{ id: 1, active: true },
{ id: 2, active: false }
]);
const users = await usersService.getActiveUsers();
expect(users).toHaveLength(1);
});
Абстракции позволяют легко переключать источники данных.
export const usersApi =
process.env.NODE_ENV === 'test'
? mockUsersApi
: realUsersApi;
Частая ошибка — создание слишком большого количества слоёв.
Component
↓
Custom Hook
↓
Query Factory
↓
Repository
↓
Service
↓
Adapter
↓
Api Client
↓
Fetch Wrapper
↓
Fetch
Каждый слой добавляет:
Для большинства приложений достаточно:
TanStack Query
↓
API Layer
↓
HTTP Client
или:
TanStack Query
↓
Service Layer
↓
API Layer
↓
HTTP Client
Сложная архитектура оправдана при наличии:
Избыточные уровни вредны в:
В таких случаях прямой API-layer обычно оказывается наиболее эффективным решением.