Организация кода

При использовании TanStack Query структура проекта начинает играть критическую роль. Небольшие примеры с одним useQuery быстро превращаются в сложную систему из запросов, мутаций, зависимостей, кэширования, optimistic updates, SSR, prefetching и синхронизации между экранами. Без чёткой организации кода приложение становится трудно поддерживать уже через несколько месяцев.

Главная цель архитектуры — разделить:

  • сетевой слой;
  • конфигурацию запросов;
  • бизнес-логику;
  • UI;
  • глобальную работу с кэшем;
  • переиспользуемые query keys;
  • мутации;
  • обработку ошибок;
  • типизацию.

Проблемы хаотичной структуры

Наиболее распространённая ошибка — размещение всей логики прямо внутри компонентов:

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

На раннем этапе такой код кажется простым, но позже появляются проблемы:

  • одинаковые запросы дублируются;
  • query keys начинают расходиться;
  • типы копируются;
  • invalidateQueries вызывается хаотично;
  • сложно менять API;
  • тяжело тестировать;
  • невозможно централизованно управлять retry и staleTime;
  • мутации начинают ломать консистентность кэша.

Базовое разделение слоёв

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

src/
├── api/
├── queries/
├── mutations/
├── hooks/
├── services/
├── entities/
├── shared/
└── pages/

Каждый слой отвечает только за свою область.


Слой API

Слой 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()
}

Важные особенности:

  • нет React;
  • нет useQuery;
  • нет queryKey;
  • нет бизнес-логики;
  • только транспортный слой.

Это позволяет:

  • переиспользовать API вне React;
  • писать unit-тесты;
  • заменить fetch на axios;
  • централизовать interceptors;
  • изолировать HTTP-логику.

Query Factory Pattern

Одна из лучших практик для 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))

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

  • единый источник query keys;
  • отсутствие опечаток;
  • переиспользование;
  • удобный invalidate;
  • улучшенная типизация.

Централизация Query Keys

Ключи — фундамент TanStack Query.

Хаотичные ключи:

['user']
['users']
['user-list']
['profile']

создают проблемы:

  • invalidateQueries не работает корректно;
  • появляются дубликаты кэша;
  • возникают лишние запросы;
  • сложно понять структуру данных.

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

export const queryKeys = {
    users: {
        all: ['users'],
        detail: (id: string) => ['users', id]
    },

    posts: {
        all: ['posts'],
        detail: (id: string) => ['posts', id]
    }
}

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

Очень распространённая ошибка — смешивание запросов и мутаций.

Неправильно:

// users.ts

export function useUser() {}
export function useCreateUser() {}
export function useDeleteUser() {}

При росте проекта файл становится огромным.

Лучше:

queries/
├── users/
│   ├── queries.ts
│   ├── mutations.ts
│   ├── keys.ts
│   └── types.ts

Feature-Sliced подход

Для крупных приложений особенно хорошо работает feature-based структура.

Пример:

entities/
├── user/
│   ├── api/
│   ├── queries/
│   ├── mutations/
│   ├── hooks/
│   ├── types/
│   └── ui/

├── post/
├── comment/

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

  • изоляция доменов;
  • меньше связности;
  • проще масштабирование;
  • независимое развитие модулей;
  • уменьшение циклических зависимостей.

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

TanStack Query управляет server state.

Ошибка многих проектов:

const [users, setUsers] = useState([])

после чего данные дублируются из query cache.

Правильно:

const usersQuery = useQuery(...)

Client state должен хранить:

  • состояние модальных окон;
  • фильтры UI;
  • локальные формы;
  • hover/focus;
  • переключатели интерфейса.

Server state:

  • API-данные;
  • сущности;
  • пагинация;
  • списки;
  • профиль;
  • результаты поиска.

Организация кастомных хуков

Кастомные хуки должны скрывать детали TanStack Query.

Плохо:

const query = useQuery(...)

во множестве компонентов.

Лучше:

// hooks/useUser.ts

export function useUser(id: string) {
    return useQuery(usersQueries.detailQuery(id))
}

Теперь компоненты ничего не знают о:

  • query keys;
  • fetchers;
  • staleTime;
  • retry;
  • select;
  • gcTime.

Инкапсуляция бизнес-логики

Частая ошибка — размещение трансформации данных в UI.

Плохо:

const users = query.data?.filter(user => user.active)

Лучше:

export function useActiveUsers() {
    return useQuery({
        ...usersQueries.list(),
        select: users => users.filter(user => user.active)
    })
}

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

  • переиспользование;
  • отсутствие дублирования;
  • единая логика;
  • более чистый UI.

Организация infinite queries

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 updates

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 Factory

В больших проектах полезно выделять query options.

Пример:

export const userQueryOptions = {
    staleTime: 1000 * 60,
    gcTime: 1000 * 60 * 10,
    retry: 2
}

Использование:

useQuery({
    ...usersQueries.detailQuery(id),
    ...userQueryOptions
})

Глобальная конфигурация QueryClient

Критически важная часть архитектуры.

Пример:

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

Нельзя хаотично задавать настройки в каждом запросе.


Централизация invalidateQueries

Ошибка:

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;
  • API response;
  • domain entities;
  • form models;
  • mutation payloads.

DTO и domain model

Очень важное разделение.

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

select — мощный инструмент для нормализации данных.

Пример:

useQuery({
    ...usersQueries.all(),

    select: users => {
        return users.sort((a, b) =>
            a.name.localeCompare(b.name)
        )
    }
})

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

  • UI получает готовые данные;
  • меньше ререндеров;
  • единая трансформация;
  • отсутствие дублирования.

Нормализация данных

Крупные проекты часто используют нормализованный кэш.

Пример структуры:

{
    users: {
        1: {...},
        2: {...}
    }
}

Это уменьшает:

  • дублирование;
  • invalidate;
  • рассинхронизацию;
  • количество обновлений.

Разделение SSR и CSR логики

При использовании 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>
}

Лучше:

  • global error boundaries;
  • centralized notifications;
  • error mappers.

Пример:

export function mapApiError(error: unknown) {
    if (isUnauthorized(error)) {
        return 'Требуется авторизация'
    }

    return 'Неизвестная ошибка'
}

Разделение публичных и приватных запросов

Полезно разделять:

queries/
├── public/
├── private/

или:

api/
├── auth/
├── public/

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

  • SSR;
  • refresh token;
  • cookies;
  • middleware;
  • авторизации.

Dependency Injection для API

Крупные приложения часто инкапсулируют API-клиент.

Пример:

export class UsersApi {
    constructor(private client: HttpClient) {}

    getUser(id: string) {
        return this.client.get(`/users/${id}`)
    }
}

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

  • тестируемость;
  • mock API;
  • переиспользование;
  • независимость от fetch/axios.

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

Polling лучше выносить в отдельные hooks.

Пример:

export function useNotificationsPolling() {
    return useQuery({
        queryKey: ['notifications'],
        queryFn: fetchNotifications,
        refetchInterval: 5000
    })
}

Разделение query hooks и UI hooks

Ошибка:

useUserModal()

внутри которого смешаны:

  • модальное окно;
  • запрос;
  • мутация;
  • toast;
  • navigation.

Лучше разделять:

hooks/
├── queries/
├── ui/
├── mutations/

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

Prefetching должен быть централизован.

Пример:

export async function prefetchUser(
    queryClient: QueryClient,
    id: string
) {
    await queryClient.prefetchQuery(
        usersQueries.detailQuery(id)
    )
}

Barrel-файлы

Полезны для упрощения импортов.

Пример:

// queries/index.ts

export * from './users'
export * from './posts'

Но чрезмерное использование barrel-файлов может:

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

Организация query key hierarchy

Хорошая иерархия:

['users']
['users', 'list']
['users', 'detail', id]
['users', 'posts', id]

Плохая:

['user-list']
['user-detail']
['posts-user']

Иерархия критически важна для:

  • partial invalidation;
  • группового refetch;
  • предсказуемости кэша.

Shared utilities

Полезно выделять:

shared/
├── query/
│   ├── createQueryKey.ts
│   ├── createMutation.ts
│   ├── invalidate.ts
│   └── cache.ts

Изоляция side effects

Побочные эффекты нельзя размазывать по компонентам.

Плохо:

onSuccess: () => {
    toast.success('Успешно')
    navigate('/profile')
}

в десятках мест.

Лучше создавать orchestrator hooks:

export function useCreateUserFlow() {
    const navigate = useNavigate()

    return useMutation({
        mutationFn: createUser,

        onSuccess: () => {
            navigate('/users')
        }
    })
}

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

В monorepo TanStack Query обычно выносится в:

packages/
├── api/
├── query/
├── ui/
├── shared/

Это позволяет:

  • переиспользовать query factories;
  • делить типы;
  • унифицировать API;
  • создавать единый data layer.

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

При росте проекта обычно появляются:

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

TanStack Query чаще всего располагается:

  • в entities;
  • в features;
  • частично в shared.

Антипаттерны организации кода

Огромные hooks

Плохо:

useDashboardData()

который:

  • делает 15 запросов;
  • содержит мутации;
  • управляет модалками;
  • форматирует данные;
  • хранит UI state.

Query key literals

Плохо:

['users']
['users']
['users']

в сотнях файлов.


Fetch внутри компонентов

Плохо:

useQuery({
    queryFn: async () => {
        ...
    }
})

Глобальный shared cache без структуры

Плохо:

setQueryData(['data'], ...)

Отсутствие domain boundaries

Когда:

  • users обновляют posts;
  • comments знают про auth;
  • profile управляет notifications.

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

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/