Структура проекта

Грамотно организованная структура проекта при использовании TanStack Query влияет на масштабируемость, предсказуемость кеширования, повторное использование запросов и удобство сопровождения кода. Ошибки архитектуры чаще всего проявляются не в небольших приложениях, а в средних и крупных системах, где десятки компонентов начинают обращаться к серверу одновременно.

Наиболее распространённая проблема — смешивание UI-логики, HTTP-клиентов, query-ключей и бизнес-логики внутри React-компонентов. В результате:

  • запросы дублируются;
  • queryKey становятся несогласованными;
  • invalidateQueries начинает работать непредсказуемо;
  • появляются циклические зависимости;
  • ухудшается повторное использование логики;
  • усложняется SSR и тестирование.

Базовые принципы организации

При построении архитектуры с TanStack Query обычно выделяют несколько уровней:

  1. HTTP-слой
  2. API-слой
  3. Query hooks
  4. UI-компоненты
  5. Query key factories
  6. Общие утилиты
  7. Domain modules

Разделение ответственности позволяет:

  • изолировать сетевую логику;
  • централизовать кеширование;
  • избежать копирования queryKey;
  • упрощать миграции API;
  • переиспользовать запросы между страницами.

Типичная структура проекта

Пример структуры крупного приложения:

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/

Внутри каждого домена:

  • API;
  • query hooks;
  • mutations;
  • query keys;
  • типы;
  • transformers;
  • selectors.

HTTP-клиент

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
    },
})

Причины:

  • повторение кода;
  • отсутствие централизованной обработки ошибок;
  • сложность подмены клиента;
  • отсутствие interceptor-логики.

API-слой

API-функции должны быть чистыми и независимыми от React.

Пример:

// users.api.ts

import { apiClient } from '@/api/client/axios'

export const getUsers = async () => {
    const response = await apiClient.get('/users')

    return response.data
}

Преимущества:

  • API можно тестировать отдельно;
  • функции переиспользуются;
  • SSR становится проще;
  • queryFn остаётся минимальным.

Query hooks

Следующий слой — 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-компоненты больше не знают:

  • как устроен API;
  • какой используется HTTP-клиент;
  • какие queryKey применяются;
  • как происходит кеширование.

Query Key Factory

Одна из важнейших архитектурных практик.

Проблема строковых ключей

Плохо:

['users']
['user', id]
['users-list']
['users_data']

Появляются:

  • опечатки;
  • несовместимые invalidateQueries;
  • несогласованность структуры кеша.

Централизация query keys

Правильный подход:

// 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),
})

Преимущества factory-подхода

Централизованное управление кешем

queryClient.invalidateQueries({
    queryKey: usersKeys.all,
})

Автодополнение TypeScript

IDE начинает понимать структуру queryKey.

Единая иерархия кеша

users
├── list
├── detail
└── permissions

Разделение queries и mutations

В небольших проектах допустимо хранить всё в одном файле.

Но в средних и крупных системах лучше разделять:

users/
├── users.api.ts
├── users.keys.ts
├── users.queries.ts
├── users.mutations.ts

Причины:

  • mutations быстро разрастаются;
  • invalidate-логика становится сложной;
  • optimistic updates требуют отдельной организации.

Организация mutations

Пример:

// 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(),
            })
        },
    })
}

Shared Query Client

QueryClient должен быть singleton.

Плохо:

const queryClient = new QueryClient()

внутри компонента.

Правильно:

// shared/queryClient.ts

import { QueryClient } from '@tanstack/react-query'

export const queryClient = new QueryClient()

Provider-слой

Обычно 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 queries

Для infinite-запросов лучше создавать отдельные hooks.

Плохо:

useUsersQuery({ infinite: true })

Правильно:

useInfiniteUsersQuery()

Причины:

  • различная структура данных;
  • разные queryKey;
  • разные pageParams;
  • разные стратегии кеширования.

Selectors и трансформация данных

Не рекомендуется трансформировать данные внутри компонентов.

Плохо:

const users = data?.map(...)

Правильно:

useQuery({
    queryKey,
    queryFn,
    select: transformUsers,
})

Структура selectors

users/
├── users.selectors.ts

Пример:

export const selectActiveUsers = (users) => {
    return users.filter(user => user.active)
}

Разделение server state и client state

Одна из главных архитектурных ошибок — хранение server state в Zustand, Redux или Context.

TanStack Query уже решает:

  • кеширование;
  • синхронизацию;
  • refetching;
  • background updates;
  • deduplication.

Redux/Zustand лучше использовать для:

  • модальных окон;
  • UI-флагов;
  • локального состояния;
  • wizard-логики;
  • theme state.

Feature-based архитектура

Крупные приложения часто используют 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 и структура проекта

При использовании SSR появляется дополнительный слой:

server/
hydration/
prefetch/

Пример prefetch:

await queryClient.prefetchQuery({
    queryKey: usersKeys.list(),
    queryFn: getUsers,
})

Изоляция query hooks от API позволяет легко выполнять prefetching на сервере.


Организация optimistic updates

Для сложных optimistic updates удобно создавать отдельные утилиты.

Пример:

users/
├── optimistic/
│   └── updateUser.optimistic.ts

Это особенно важно при:

  • rollback;
  • snapshot;
  • complex cache patching.

Кеш как иерархия

Полезно воспринимать query cache как дерево.

Пример:

users
├── list
│   ├── active
│   └── archived
│
├── detail
│   ├── 1
│   ├── 2
│   └── 3

Тогда invalidateQueries начинает работать предсказуемо.


Антипаттерн: inline query keys

Плохо:

useQuery({
    queryKey: ['users', 'active'],
})

Потому что:

  • ключ невозможно переиспользовать;
  • invalidate становится хрупким;
  • сложно поддерживать единый namespace.

Антипаттерн: queryFn внутри компонента

Плохо:

const Users = () => {
    const query = useQuery({
        queryKey: ['users'],
        queryFn: async () => {
            const response = await api.get('/users')
            return response.data
        },
    })
}

Проблемы:

  • отсутствие переиспользования;
  • сложность тестирования;
  • разрастание компонентов;
  • смешение слоёв архитектуры.

Антипаттерн: глобальная папка hooks

Плохо:

hooks/
├── useUsers.ts
├── usePosts.ts
├── useComments.ts

При большом проекте папка становится хаотичной.


Антипаттерн: смешивание UI и data layer

Плохо:

if (isAdmin) {
    queryClient.invalidateQueries(...)
}

Компоненты не должны знать детали кеширования.


Подход shared + entities + features

В больших приложениях часто применяется структура:

src/
├── app/
├── shared/
├── entities/
├── features/
├── widgets/
├── pages/

TanStack Query обычно располагается:

  • entities;
  • features;
  • shared/api.

Shared API utilities

Полезно выносить:

shared/api/
├── client.ts
├── queryClient.ts
├── errors.ts
├── auth.ts
├── retry.ts

Организация retry-логики

Не рекомендуется копировать retry:

retry: 3

в каждом query.

Лучше:

const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            retry: 2,
            staleTime: 1000 * 30,
        },
    },
})

Разделение transport и business logic

API-слой:

getUsers()

Business layer:

getActiveUsers()

Не следует смешивать:

  • HTTP;
  • трансформацию;
  • бизнес-правила;
  • UI-логику.

Barrel exports

Полезно использовать index.ts:

users/
├── index.ts

Пример:

export * from './users.queries'
export * from './users.mutations'

Naming conventions

Хорошая практика:

users.api.ts
users.keys.ts
users.queries.ts
users.mutations.ts

Это делает структуру предсказуемой.


Масштабирование архитектуры

По мере роста приложения появляются:

  • websocket updates;
  • cache synchronization;
  • persisted cache;
  • offline mode;
  • broadcast synchronization;
  • streaming;
  • suspense boundaries.

Без правильной структуры TanStack Query быстро превращается в хаотичный слой сетевой логики.


Рекомендуемая архитектура для средних и крупных проектов

src/
├── app/
│
├── shared/
│   ├── api/
│   ├── lib/
│   └── config/
│
├── entities/
│   ├── user/
│   ├── post/
│   └── comment/
│
├── features/
│   ├── auth/
│   ├── create-post/
│   └── update-profile/
│
├── widgets/
│
├── pages/
│
└── processes/

Такая организация:

  • хорошо масштабируется;
  • уменьшает связанность;
  • упрощает refactoring;
  • делает кеш предсказуемым;
  • улучшает поддержку SSR;
  • облегчает командную разработку;
  • снижает количество архитектурных конфликтов.