Грамотно организованная структура проекта при использовании TanStack Query влияет на масштабируемость, предсказуемость кеширования, повторное использование запросов и удобство сопровождения кода. Ошибки архитектуры чаще всего проявляются не в небольших приложениях, а в средних и крупных системах, где десятки компонентов начинают обращаться к серверу одновременно.
Наиболее распространённая проблема — смешивание UI-логики, HTTP-клиентов, query-ключей и бизнес-логики внутри React-компонентов. В результате:
При построении архитектуры с TanStack Query обычно выделяют несколько уровней:
Разделение ответственности позволяет:
Пример структуры крупного приложения:
src/
├── api/
│ ├── client/
│ │ ├── axios.ts
│ │ └── fetcher.ts
│ │
│ ├── users/
│ │ ├── users.api.ts
│ │ ├── users.keys.ts
│ │ ├── users.queries.ts
│ │ ├── users.mutations.ts
│ │ └── types.ts
│ │
│ ├── posts/
│ │ ├── posts.api.ts
│ │ ├── posts.keys.ts
│ │ ├── posts.queries.ts
│ │ ├── posts.mutations.ts
│ │ └── types.ts
│
├── components/
│
├── pages/
│
├── hooks/
│
├── providers/
│ └── QueryProvider.tsx
│
├── utils/
│
└── app/
Такая структура позволяет хранить всё, связанное с определённым доменом, в одном месте.
Одна из лучших практик — организация проекта по feature/domain-подходу, а не по типу файлов.
Плохой вариант:
hooks/
components/
services/
queries/
mutations/
При росте проекта файлы начинают терять связь между собой.
Гораздо эффективнее:
users/
posts/
comments/
notifications/
Внутри каждого домена:
HTTP-клиент должен быть полностью изолирован.
Пример:
// api/client/axios.ts
import axios from 'axios'
export const apiClient = axios.create({
baseURL: '/api',
withCredentials: true,
})
Не рекомендуется вызывать axios напрямую внутри useQuery.
Плохо:
useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await axios.get('/users')
return response.data
},
})
Причины:
API-функции должны быть чистыми и независимыми от React.
Пример:
// users.api.ts
import { apiClient } from '@/api/client/axios'
export const getUsers = async () => {
const response = await apiClient.get('/users')
return response.data
}
Преимущества:
Следующий слой — custom hooks.
Пример:
// users.queries.ts
import { useQuery } from '@tanstack/react-query'
import { getUsers } from './users.api'
import { usersKeys } from './users.keys'
export const useUsersQuery = () => {
return useQuery({
queryKey: usersKeys.list(),
queryFn: getUsers,
})
}
UI-компоненты больше не знают:
Одна из важнейших архитектурных практик.
Плохо:
['users']
['user', id]
['users-list']
['users_data']
Появляются:
Правильный подход:
// users.keys.ts
export const usersKeys = {
all: ['users'] as const,
lists: () => [...usersKeys.all, 'list'] as const,
list: (filters?: string) =>
[...usersKeys.lists(), filters] as const,
detail: (id: number) =>
[...usersKeys.all, 'detail', id] as const,
}
Использование:
useQuery({
queryKey: usersKeys.detail(userId),
queryFn: () => getUser(userId),
})
queryClient.invalidateQueries({
queryKey: usersKeys.all,
})
IDE начинает понимать структуру queryKey.
users
├── list
├── detail
└── permissions
В небольших проектах допустимо хранить всё в одном файле.
Но в средних и крупных системах лучше разделять:
users/
├── users.api.ts
├── users.keys.ts
├── users.queries.ts
├── users.mutations.ts
Причины:
Пример:
// users.mutations.ts
import { useMutation } from '@tanstack/react-query'
import { createUser } from './users.api'
import { usersKeys } from './users.keys'
import { queryClient } from '@/shared/queryClient'
export const useCreateUserMutation = () => {
return useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: usersKeys.lists(),
})
},
})
}
QueryClient должен быть singleton.
Плохо:
const queryClient = new QueryClient()
внутри компонента.
Правильно:
// shared/queryClient.ts
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient()
Обычно QueryClientProvider выносится отдельно.
Пример:
// providers/QueryProvider.tsx
import {
QueryClientProvider,
} from '@tanstack/react-query'
import { queryClient } from '@/shared/queryClient'
export const QueryProvider = ({ children }) => {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
Для infinite-запросов лучше создавать отдельные hooks.
Плохо:
useUsersQuery({ infinite: true })
Правильно:
useInfiniteUsersQuery()
Причины:
Не рекомендуется трансформировать данные внутри компонентов.
Плохо:
const users = data?.map(...)
Правильно:
useQuery({
queryKey,
queryFn,
select: transformUsers,
})
users/
├── users.selectors.ts
Пример:
export const selectActiveUsers = (users) => {
return users.filter(user => user.active)
}
Одна из главных архитектурных ошибок — хранение server state в Zustand, Redux или Context.
TanStack Query уже решает:
Redux/Zustand лучше использовать для:
Крупные приложения часто используют feature slices.
Пример:
features/
├── auth/
├── users/
├── billing/
├── notifications/
Внутри feature:
users/
├── api/
├── hooks/
├── components/
├── pages/
├── types/
Типы лучше хранить рядом с доменом.
users/
├── types.ts
Пример:
export interface User {
id: number
email: string
name: string
}
Не рекомендуется создавать один гигантский global types.ts.
При использовании SSR появляется дополнительный слой:
server/
hydration/
prefetch/
Пример prefetch:
await queryClient.prefetchQuery({
queryKey: usersKeys.list(),
queryFn: getUsers,
})
Изоляция query hooks от API позволяет легко выполнять prefetching на сервере.
Для сложных optimistic updates удобно создавать отдельные утилиты.
Пример:
users/
├── optimistic/
│ └── updateUser.optimistic.ts
Это особенно важно при:
Полезно воспринимать query cache как дерево.
Пример:
users
├── list
│ ├── active
│ └── archived
│
├── detail
│ ├── 1
│ ├── 2
│ └── 3
Тогда invalidateQueries начинает работать предсказуемо.
Плохо:
useQuery({
queryKey: ['users', 'active'],
})
Потому что:
Плохо:
const Users = () => {
const query = useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await api.get('/users')
return response.data
},
})
}
Проблемы:
Плохо:
hooks/
├── useUsers.ts
├── usePosts.ts
├── useComments.ts
При большом проекте папка становится хаотичной.
Плохо:
if (isAdmin) {
queryClient.invalidateQueries(...)
}
Компоненты не должны знать детали кеширования.
В больших приложениях часто применяется структура:
src/
├── app/
├── shared/
├── entities/
├── features/
├── widgets/
├── pages/
TanStack Query обычно располагается:
Полезно выносить:
shared/api/
├── client.ts
├── queryClient.ts
├── errors.ts
├── auth.ts
├── retry.ts
Не рекомендуется копировать retry:
retry: 3
в каждом query.
Лучше:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 1000 * 30,
},
},
})
API-слой:
getUsers()
Business layer:
getActiveUsers()
Не следует смешивать:
Полезно использовать index.ts:
users/
├── index.ts
Пример:
export * from './users.queries'
export * from './users.mutations'
Хорошая практика:
users.api.ts
users.keys.ts
users.queries.ts
users.mutations.ts
Это делает структуру предсказуемой.
По мере роста приложения появляются:
Без правильной структуры TanStack Query быстро превращается в хаотичный слой сетевой логики.
src/
├── app/
│
├── shared/
│ ├── api/
│ ├── lib/
│ └── config/
│
├── entities/
│ ├── user/
│ ├── post/
│ └── comment/
│
├── features/
│ ├── auth/
│ ├── create-post/
│ └── update-profile/
│
├── widgets/
│
├── pages/
│
└── processes/
Такая организация: