При использовании TanStack Query структура проекта начинает играть
критическую роль. Небольшие примеры с одним useQuery быстро
превращаются в сложную систему из запросов, мутаций, зависимостей,
кэширования, optimistic updates, SSR, prefetching и синхронизации между
экранами. Без чёткой организации кода приложение становится трудно
поддерживать уже через несколько месяцев.
Главная цель архитектуры — разделить:
Наиболее распространённая ошибка — размещение всей логики прямо внутри компонентов:
function UserPage() {
const query = useQuery({
queryKey: ['user'],
queryFn: async () => {
const response = await fetch('/api/user')
if (!response.ok) {
throw new Error('Ошибка')
}
return response.json()
}
})
if (query.isLoading) {
return <Loader />
}
return <div>{query.data.name}</div>
}
На раннем этапе такой код кажется простым, но позже появляются проблемы:
Практически любой крупный проект с TanStack Query постепенно приходит к следующей структуре:
src/
├── api/
├── queries/
├── mutations/
├── hooks/
├── services/
├── entities/
├── shared/
└── pages/
Каждый слой отвечает только за свою область.
Слой API содержит исключительно работу с HTTP.
Пример:
// api/users.ts
export async function getUser(id: string) {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) {
throw new Error('Ошибка загрузки пользователя')
}
return response.json()
}
Важные особенности:
Это позволяет:
Одна из лучших практик для TanStack Query — фабрики запросов.
Плохой вариант:
useQuery({
queryKey: ['users', id],
queryFn: () => getUser(id)
})
Проблема в том, что queryKey размазываются по проекту.
Лучший подход:
// queries/users.ts
export const usersQueries = {
all: () => ['users'],
detail: (id: string) => ['users', id],
detailQuery: (id: string) => ({
queryKey: ['users', id],
queryFn: () => getUser(id)
})
}
Использование:
useQuery(usersQueries.detailQuery(id))
Преимущества:
Ключи — фундамент TanStack Query.
Хаотичные ключи:
['user']
['users']
['user-list']
['profile']
создают проблемы:
Правильный подход:
export const queryKeys = {
users: {
all: ['users'],
detail: (id: string) => ['users', id]
},
posts: {
all: ['posts'],
detail: (id: string) => ['posts', id]
}
}
Очень распространённая ошибка — смешивание запросов и мутаций.
Неправильно:
// users.ts
export function useUser() {}
export function useCreateUser() {}
export function useDeleteUser() {}
При росте проекта файл становится огромным.
Лучше:
queries/
├── users/
│ ├── queries.ts
│ ├── mutations.ts
│ ├── keys.ts
│ └── types.ts
Для крупных приложений особенно хорошо работает feature-based структура.
Пример:
entities/
├── user/
│ ├── api/
│ ├── queries/
│ ├── mutations/
│ ├── hooks/
│ ├── types/
│ └── ui/
├── post/
├── comment/
Преимущества:
TanStack Query управляет server state.
Ошибка многих проектов:
const [users, setUsers] = useState([])
после чего данные дублируются из query cache.
Правильно:
const usersQuery = useQuery(...)
Client state должен хранить:
Server state:
Кастомные хуки должны скрывать детали TanStack Query.
Плохо:
const query = useQuery(...)
во множестве компонентов.
Лучше:
// hooks/useUser.ts
export function useUser(id: string) {
return useQuery(usersQueries.detailQuery(id))
}
Теперь компоненты ничего не знают о:
Частая ошибка — размещение трансформации данных в UI.
Плохо:
const users = query.data?.filter(user => user.active)
Лучше:
export function useActiveUsers() {
return useQuery({
...usersQueries.list(),
select: users => users.filter(user => user.active)
})
}
Преимущества:
Infinite queries требуют отдельной структуры.
Пример:
queries/
├── posts/
│ ├── infinite.ts
│ ├── queries.ts
│ └── keys.ts
Фабрика:
export const postsQueries = {
infinite: () => ({
queryKey: ['posts', 'infinite'],
queryFn: fetchPosts,
initialPageParam: 1,
getNextPageParam: lastPage => lastPage.nextPage
})
}
Optimistic update нельзя писать прямо внутри компонентов.
Плохо:
useMutation({
mutationFn: updatePost,
onMutate: async () => {
...
}
})
Лучше:
// mutations/updatePost.ts
export function useUpdatePost() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updatePost,
onMutate: async updatedPost => {
await queryClient.cancelQueries({
queryKey: ['posts']
})
const previous =
queryClient.getQueryData(['posts'])
queryClient.setQueryData(
['posts'],
old => {
return old.map(post =>
post.id === updatedPost.id
? updatedPost
: post
)
}
)
return { previous }
},
onError: (_error, _variables, context) => {
queryClient.setQueryData(
['posts'],
context?.previous
)
}
})
}
В больших проектах полезно выделять query options.
Пример:
export const userQueryOptions = {
staleTime: 1000 * 60,
gcTime: 1000 * 60 * 10,
retry: 2
}
Использование:
useQuery({
...usersQueries.detailQuery(id),
...userQueryOptions
})
Критически важная часть архитектуры.
Пример:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 1000 * 30,
refetchOnWindowFocus: false
}
}
})
Нельзя хаотично задавать настройки в каждом запросе.
Ошибка:
queryClient.invalidateQueries(['users'])
queryClient.invalidateQueries(['posts'])
queryClient.invalidateQueries(['comments'])
в разных местах приложения.
Лучше создавать отдельные helper-функции:
export function invalidateUserQueries(
queryClient: QueryClient
) {
return queryClient.invalidateQueries({
queryKey: ['users']
})
}
Типы должны находиться рядом с доменной областью.
Пример:
entities/
├── user/
│ ├── types/
│ │ ├── user.ts
│ │ ├── dto.ts
│ │ └── requests.ts
Разделение:
Очень важное разделение.
DTO:
export interface UserDto {
first_name: string
last_name: string
}
Domain model:
export interface User {
firstName: string
lastName: string
}
Трансформация:
export function mapUser(dto: UserDto): User {
return {
firstName: dto.first_name,
lastName: dto.last_name
}
}
Это защищает приложение от изменений backend.
select — мощный инструмент для нормализации данных.
Пример:
useQuery({
...usersQueries.all(),
select: users => {
return users.sort((a, b) =>
a.name.localeCompare(b.name)
)
}
})
Преимущества:
Крупные проекты часто используют нормализованный кэш.
Пример структуры:
{
users: {
1: {...},
2: {...}
}
}
Это уменьшает:
При использовании Next.js особенно важно отделять hydration.
Пример:
server/
├── prefetch/
├── dehydration/
Prefetch:
await queryClient.prefetchQuery(
usersQueries.detailQuery(id)
)
Hydration:
<HydrationBoundary state={dehydratedState}>
<Page />
</HydrationBoundary>
Ошибка многих проектов — локальная обработка ошибок в каждом компоненте.
Плохо:
if (query.isError) {
return <div>Error</div>
}
Лучше:
Пример:
export function mapApiError(error: unknown) {
if (isUnauthorized(error)) {
return 'Требуется авторизация'
}
return 'Неизвестная ошибка'
}
Полезно разделять:
queries/
├── public/
├── private/
или:
api/
├── auth/
├── public/
Это особенно важно при:
Крупные приложения часто инкапсулируют API-клиент.
Пример:
export class UsersApi {
constructor(private client: HttpClient) {}
getUser(id: string) {
return this.client.get(`/users/${id}`)
}
}
Преимущества:
Polling лучше выносить в отдельные hooks.
Пример:
export function useNotificationsPolling() {
return useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
refetchInterval: 5000
})
}
Ошибка:
useUserModal()
внутри которого смешаны:
Лучше разделять:
hooks/
├── queries/
├── ui/
├── mutations/
Prefetching должен быть централизован.
Пример:
export async function prefetchUser(
queryClient: QueryClient,
id: string
) {
await queryClient.prefetchQuery(
usersQueries.detailQuery(id)
)
}
Полезны для упрощения импортов.
Пример:
// queries/index.ts
export * from './users'
export * from './posts'
Но чрезмерное использование barrel-файлов может:
Хорошая иерархия:
['users']
['users', 'list']
['users', 'detail', id]
['users', 'posts', id]
Плохая:
['user-list']
['user-detail']
['posts-user']
Иерархия критически важна для:
Полезно выделять:
shared/
├── query/
│ ├── createQueryKey.ts
│ ├── createMutation.ts
│ ├── invalidate.ts
│ └── cache.ts
Побочные эффекты нельзя размазывать по компонентам.
Плохо:
onSuccess: () => {
toast.success('Успешно')
navigate('/profile')
}
в десятках мест.
Лучше создавать orchestrator hooks:
export function useCreateUserFlow() {
const navigate = useNavigate()
return useMutation({
mutationFn: createUser,
onSuccess: () => {
navigate('/users')
}
})
}
В monorepo TanStack Query обычно выносится в:
packages/
├── api/
├── query/
├── ui/
├── shared/
Это позволяет:
При росте проекта обычно появляются:
src/
├── app/
├── processes/
├── entities/
├── features/
├── widgets/
├── pages/
├── shared/
TanStack Query чаще всего располагается:
Плохо:
useDashboardData()
который:
Плохо:
['users']
['users']
['users']
в сотнях файлов.
Плохо:
useQuery({
queryFn: async () => {
...
}
})
Плохо:
setQueryData(['data'], ...)
Когда:
src/
├── app/
│ ├── providers/
│ ├── router/
│ └── query-client/
├── shared/
│ ├── api/
│ ├── lib/
│ ├── query/
│ └── types/
├── entities/
│ ├── user/
│ │ ├── api/
│ │ ├── query/
│ │ ├── model/
│ │ ├── hooks/
│ │ ├── types/
│ │ └── ui/
│ │
│ ├── post/
│ └── comment/
├── features/
│ ├── auth/
│ ├── create-post/
│ └── update-profile/
├── widgets/
│ ├── sidebar/
│ ├── navbar/
│ └── dashboard/
└── pages/