Коротко: Руководство по созданию типобезопасной Query Factory в TanStack Query v5 с TypeScript: архитектура ключей, хелпер queryOptions и инвалидация кэша.
В масштабных React-приложениях работа с серверным состоянием быстро обрастает техническим долгом, если управление ключами кэша и функциями запросов децентрализовано. Разработчики объявляют строковые массивы queryKey прямо в компонентах, дублируют вызовы fetch и вручную прописывают generic-типы в хуках. В результате любая смена контракта API или попытка точечной инвалидации кэша превращается в рутину с поиском строк по всей кодовой базе и высоким риском допустить опечатку.
Паттерн Query Factory решает эту проблему за счет централизации определений запросов в типизированные фабрики. С выходом TanStack Query v5 этот подход стал еще проще и надежнее благодаря встроенному хелперу queryOptions.
Эволюция TanStack Query v5: предпосылки к созданию фабрик
В пятой версии библиотеки произошли фундаментальные изменения архитектуры API, которые сделали фабричный подход стандартной практикой.
Отказ от позиционных перегрузок
В v3 и v4 хук useQuery поддерживал множество вариантов вызова: передачу ключа первым аргументом, функции вторым, а объекта опций третьим. Это усложняло поддержку типов и приводило к громоздким сигнатурам. В TanStack Query v5 все перегрузки удалены: хуки принимают исключительно один объект конфигурации.
// TanStack Query v5: строгий единый объект конфигурации
const { data } = useQuery({
queryKey: ['users', 'detail', userId],
queryFn: () => fetchUserById(userId),
staleTime: 5 * 60 * 1000,
});
Хелпер queryOptions и сквозная типизация
Главным нововведением v5 стал хелпер queryOptions. Он выполняет роль типобезопасного конструктора объекта конфигурации запроса.
import { queryOptions } from '@tanstack/react-query';
export const userQueries = {
detail: (id: string) =>
queryOptions({
queryKey: ['users', 'detail', id] as const,
queryFn: () => fetchUserById(id),
}),
};
TypeScript автоматически выводит тип возвращаемых данных (TData) и тип ошибки (TError) напрямую из queryFn. Разработчику больше не нужно вручную прописывать дженерики useQuery<User, Error>(...).
Архитектура ключей: иерархический подход к организации кэша
Ключи кэша в TanStack Query представляют собой массивы. Порядок элементов и уровень их вложенности определяют то, как библиотека выполняет сопоставление (matching) при поиске записей в кэше.
Принцип вложенности: от общего к частному
Оптимальная схема организации ключей строится по иерархическому принципу:
[scope, entity, action, ...params]
Например, для сущности пользователей структура выглядит следующим образом:
['users']— корневой ключ области сущности (scope).['users', 'list']— все списочные представления.['users', 'list', { role: 'admin', page: 1 }]— конкретный список с фильтрацией и пагинацией.['users', 'detail']— все детальные записи.['users', 'detail', userId]— конкретный пользователь по идентификатору.
Частичное сопоставление (Fuzzy Matching)
TanStack Query по умолчанию использует не строгое равенство массивов, а проверку вхождения начальных элементов. Если вызвать инвалидацию по ключу ['users', 'list'], библиотека пометит устаревшими все запросы, чьи ключи начинаются с этих двух элементов, независимо от переданных параметров фильтрации.
// Инвалидирует и ['users', 'list', { page: 1 }], и ['users', 'list', { page: 2 }]
await queryClient.invalidateQueries({
queryKey: ['users', 'list'],
});
Пошаговая реализация типобезопасной Query Factory
Рассмотрим создание фабрики запросов для сущности Project на чистом TypeScript.
1. Определение контрактов и API-слоя
Создадим интерфейсы данных и методы выполнения запросов:
// entities/project/model/types.ts
export interface Project {
id: string;
title: string;
status: 'active' | 'archived';
}
export interface ProjectFilters {
status?: 'active' | 'archived';
search?: string;
page?: number;
}
// entities/project/api/projectApi.ts
import { Project, ProjectFilters } from '../model/types';
export const projectApi = {
getProjects: async (filters?: ProjectFilters): Promise<Project[]> => {
const params = new URLSearchParams(filters as Record<string, string>);
const res = await fetch(`/api/projects?${params}`);
if (!res.ok) throw new Error('Ошибка при загрузке проектов');
return res.json();
},
getProjectById: async (id: string): Promise<Project> => {
const res = await fetch(`/api/projects/${id}`);
if (!res.ok) throw new Error('Ошибка при загрузке проекта');
return res.json();
},
};
2. Сборка объекта Query Factory
Используем queryOptions для создания методов фабрики. Выделяем иерархию ключей и связываем их с функциями загрузки:
// entities/project/api/projectQueries.ts
import { queryOptions } from '@tanstack/react-query';
import { projectApi } from './projectApi';
import { ProjectFilters } from '../model/types';
export const projectQueries = {
// Базовый ключ для всей сущности
all: () => ['projects'] as const,
// Ключи для списков
lists: () => [...projectQueries.all(), 'list'] as const,
list: (filters?: ProjectFilters) =>
queryOptions({
queryKey: [...projectQueries.lists(), { ...(filters ?? {}) }] as const,
queryFn: ({ signal }) => projectApi.getProjects(filters),
staleTime: 60 * 1000,
}),
// Ключи для детальных записей
details: () => [...projectQueries.all(), 'detail'] as const,
detail: (id: string) =>
queryOptions({
queryKey: [...projectQueries.details(), id] as const,
queryFn: ({ signal }) => projectApi.getProjectById(id),
staleTime: 5 * 60 * 1000,
enabled: Boolean(id),
}),
};
Фабрика предоставляет как готовые объекты конфигурации (list(), detail()), так и массивы ключей (all(), lists(), details()), которые используются для инвалидации и поиска по кэшу.
Практические сценарии использования фабрики
Декларативное получение данных в компонентах
В компоненте достаточно передать результат вызова метода фабрики прямо в хук:
import React from 'react';
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { projectQueries } from '../api/projectQueries';
export const ProjectDetail = ({ projectId }: { projectId: string }) => {
// Тип data автоматически выводится как Project
const { data: project, isLoading, error } = useQuery(projectQueries.detail(projectId));
if (isLoading) return <div>Загрузка...</div>;
if (error) return <div>Ошибка: {error.message}</div>;
return (
<article>
<h1>{project.title}</h1>
<p>Статус: {project.status}</p>
</article>
);
};
export const SuspendedProjectList = () => {
// Использование с React Suspense
const { data: projects } = useSuspenseQuery(projectQueries.list({ status: 'active' }));
return (
<ul>
{projects.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
};
Prefetching на сервере и клиенте
При использовании SSR (например, в Next.js) или предзагрузке данных по ховеру мы передаем тот же объект опций в prefetchQuery. Это гарантирует совпадение ключа и функции выборки данных:
// Серверный компонент / Loader
import { QueryClient } from '@tanstack/react-query';
import { projectQueries } from '@/entities/project/api/projectQueries';
export async function preloadProject(id: string) {
const queryClient = new QueryClient();
// Единый объект конфигурации используется на сервере и клиенте
await queryClient.prefetchQuery(projectQueries.detail(id));
return queryClient;
}
Точечная и каскадная инвалидация
Управление сбросом кэша после мутаций становится прозрачным и безопасным:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { projectQueries } from '../api/projectQueries';
export const useCreateProject = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newProject: { title: string }) =>
fetch('/api/projects', {
method: 'POST',
body: JSON.stringify(newProject),
}),
onSuccess: () => {
// Инвалидируем все списки проектов (с любыми фильтрами и пагинацией)
queryClient.invalidateQueries({
queryKey: projectQueries.lists(),
});
},
});
};
Чтение и обновление кэша вручную
// Оптимистичное чтение данных конкретного проекта
const cachedProject = queryClient.getQueryData(projectQueries.detail(projectId).queryKey);
// Точечное обновление кэша
queryClient.setQueryData(
projectQueries.detail(projectId).queryKey,
(oldProject) => (oldProject ? { ...oldProject, status: 'archived' } : undefined)
);
Собственная фабрика против сторонних библиотек
В экосистеме существует решение @lukemorales/query-key-factory. Сравним подходы:
| Критерий | Нативная фабрика (queryOptions) |
Сторонняя библиотека (@lukemorales/query-key-factory) |
|---|---|---|
| Сторонние зависимости | 0 (встроено в @tanstack/react-query) |
Дополнительный npm-пакет |
| Гибкость конфигурации | Полный контроль над структурой и типами | Ограничена схемами пакета |
| Сложность внедрения | Минимальная, стандартный TS-код | Требует изучения DSL библиотеки |
| Совместимость с v5 | Нативная поддержка из коробки | Зависит от релизного цикла пакета |
Для подавляющего большинства проектов на TanStack Query v5 возможностей чистого TypeScript и queryOptions достаточно. Это исключает лишние абстракции и оставляет кодовую базу предсказуемой.
Типичные ошибки и антипаттерны
1. Отсутствие as const при формировании ключей
Если не зафиксировать массив как кортеж констант, TypeScript расширит тип до string[]. Это лишит TanStack Query возможности строго выводить типы элементов ключа.
// Плохо: тип string[]
const key = ['users', 'detail', id];
// Хорошо: readonly ['users', 'detail', string]
const key = ['users', 'detail', id] as const;
2. Ненормализованные объекты фильтров внутри ключа
Передача объектов с полями в разном порядке может приводить к разным хэшам ключа, если фильтры не нормализованы. TanStack Query детерминированно сериализует простые объекты в ключах, но передача неструктурированных undefined значений затрудняет отладку:
// Рекомендуется нормализовать объект параметров
const normalizedFilters = {
status: filters?.status ?? null,
page: filters?.page ?? 1,
};
3. Избыточное указание generic-типов в useQuery
Не переопределяйте возвращаемые типы вручную при вызове useQuery(projectQueries.detail(id)). Хелпер queryOptions уже содержит всю информацию о типах. Ручное указание дженериков повышает вероятность маскировки ошибок несовпадения типов API и интерфейса.
Часто задаваемые вопросы (FAQ)
Зачем нужна Query Factory, если можно написать кастомный хук useProjectQuery?
Кастомный хук инкапсулирует вызов внутри React-компонента. Из-за этого ключ и функцию запроса нельзя напрямую переиспользовать в prefetchQuery на сервере (SSR/RSC), в серверных функциях loader или в queryClient.invalidateQueries. Фабрика разделяет конфигурацию запроса и механизм его вызова в UI.
Как фабрика запросов упрощает рефакторинг в большой команде?
При изменении структуры endpoint или схемы ключей правки вносятся только в файл фабрики. TypeScript сразу подсветит все несовместимые места вызовов во всем проекте на этапе сборки.
Нужно ли создавать отдельные фабрики для мутаций?
Мутации (useMutation) обычно не требуют кэширования по ключам, так как они изменяют состояние, а не читают его. Для мутаций достаточно создавать стандартные хуки или функции API, а фабрику запросов использовать внутри коллбэков onSuccess или onSettled для инвалидации затронутых данных.
Как поступить, если запрос зависит от параметра, который может быть undefined?
Используйте флаг enabled внутри queryOptions. Он предотвратит автоматическое выполнение запроса до тех пор, пока параметр не станет доступен:
export const projectQueries = {
detail: (id?: string) =>
queryOptions({
queryKey: ['projects', 'detail', id] as const,
queryFn: () => projectApi.getProjectById(id!),
enabled: Boolean(id),
}),
};
Как организовать фабрики запросов по методологии Feature-Sliced Design (FSD)?
В рамках архитектуры FSD фабрику запросов оптимально размещать в сегменте api соответствующего слайса сущности (entities/{entityName}/api/{entityName}Queries.ts). Это обеспечивает доступность запросов для вышележащих слоев (features, widgets, pages) с сохранением строгой изоляции модулей.
Заключение
Паттерн Query Factory в связке с queryOptions из TanStack Query v5 систематизирует работу с серверным состоянием:
- Единый источник истины: конфигурации запросов и логика ключей кэша собраны в одном модуле.
- Строгая типобезопасность: автоматический вывод типов данных и ошибок минимизирует бойлерплейт.
- Универсальность: один объект конфигурации подходит для хуков компонентов, SSR, фонового префетчинга и инвалидации кэша.
Внедрение этого подхода уменьшает объем технического долга и делает архитектуру сетевых запросов прозрачной и поддерживаемой.




.svg.webp)





