В масштабных веб-приложениях TanStack Query (ранее React Query) v5 выполняет роль распределенного асинхронного серверного кэша. Центральным элементом этого кэша является Query Key (Ключ запроса) — сериализуемый массив литералов, идентификаторов и объектов фильтрации, служащий первичным ключом для дедупликации, хранения и инвалидации данных.
В незрелых архитектурах ключи запросов часто объявляются как разрозненные строковые массивы по всему коду компонентов:
// Хрупкий код: опечатка в одном символе ломает инвалидацию кэша в рантайме!
useQuery({ queryKey: ['users', id, 'detail'] });
queryClient.invalidateQueries({ queryKey: ['user', id, 'details'] }); // Тихий сбой!Для решения проблемы рассинхронизации в TypeScript применяется архитектурный паттерн Query Key Factory (Фабрика ключей кэша) в сочетании со строгой утилитой queryOptions(), представленной в TanStack Query v5. Этот подход гарантирует, что каждый сетевой контракт типизирован сквозным образом — от декларации эндпоинта до оптимистичных мутаций с типизированным контекстом отката (Rollback Context).
+-------------------------------------------------------------------------+
| АРХИТЕКТУРА СКВОЗНОЙ ТИПИЗАЦИИ КЭША В V5 |
+-------------------------------------------------------------------------+
| [Query Key Factory: usersKeys.detail(id)] |
| |--> Возвращает строго типизированный кортеж: readonly ['users', 'detail', string]
+-------------------------------------------------------------------------+
|
v
| [queryOptions({ queryKey, queryFn })] |
| |--> Связывает тип ключа с входными типами queryFn |
| |--> Автоматически выводит TQueryFnData, TError, TData |
| `-- Изоморфно переиспользуется: prefetchQuery (SSR) <-> useQuery (Client)|
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| [Сквозная мутация: useMutation<User, Error, UpdatePayload, Context>] |
| |-- onMutate: (vars) => Promise<{ previousUser: User | undefined }> |
| |-- onError: (err, vars, context: Context | undefined) => Rollback |
| `-- onSettled: (data, err, vars, context) => Invalidate by Factory |
+-------------------------------------------------------------------------+Типизация в TanStack Query v5 строится на иерархических проекциях и 4-компонентных дженериках:
['users']), так и конкретные выборки (['users', 'list', { role: 'admin' }]).useQuery.Иерархическая структура Query Key Factory:
userKeys.all -------------------> ['users']
|
|-- userKeys.lists() -----> ['users', 'list']
| `-- userKeys.list(filter) -> ['users', 'list', { status: 'active' }]
|
`-- userKeys.details() ---> ['users', 'detail']
`-- userKeys.detail(id) ----> ['users', 'detail', '42']
Инвалидация ['users'] автоматически сбрасывает все вложенные списки и детали!Опишите иерархический объект с методами, возвращающими readonly кортежи с as const.
Опишите конфигурации запросов сущностей с типизированными queryFn, staleTime и селекторами.
Передавайте созданный queryOptions в prefetchQuery серверных компонентов для устранения сетевых водопадов.
Сконфигурируйте useMutation с типизацией TContext для безопасного отката оптимистичных изменений.
Используйте фабрику ключей в коллбеках onSettled для детерминированного сброса устаревших выборок.
Какое ключевое преимущество дает использование Query Key Factory с кортежами as const и хелпера queryOptions() в TanStack Query v5?
MutationContext при ошибке 500.getNextPageParam и строгим контролем типов страниц InfiniteData<PageResponse>.queryOptions для серверного prefetchQuery внутри React Server Components и клиентского useSuspenseQuery.Создадим переиспользуемую фабрику ключей для сущности User без сторонних зависимостей:
export interface UserListFilters {
readonly role?: 'admin' | 'member';
readonly search?: string;
readonly page?: number;
}
/**
* Иерархическая фабрика ключей кэша пользователей.
* Каждый метод возвращает строго типизированный readonly кортеж.
*/
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserListFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (userId: string) => [...userKeys.details(), userId] as const,
};
export type UserKeys = typeof userKeys;queryOptions и селекторами данных#В TanStack Query v5 утилита queryOptions автоматически связывает возвращаемое значение queryFn с опциями:
import { queryOptions } from '@tanstack/react-query';
import { userKeys, UserListFilters } from './userKeys';
export interface UserDto {
readonly id: string;
readonly fullName: string;
readonly email: string;
readonly createdAt: string;
readonly balanceCents: number;
}
async function fetchUserById(userId: string, signal?: AbortSignal): Promise<UserDto> {
const res = await fetch(`/api/users/${userId}`, { signal });
if (!res.ok) {
throw new Error(`[ApiError] Не удалось загрузить пользователя: ${res.statusText}`);
}
return (await res.json()) as UserDto;
}
/**
* Типобезопасная конфигурация запроса пользователя.
* TQueryFnData = UserDto
* TError = Error
*/
export const userDetailQueryOptions = (userId: string) =>
queryOptions({
queryKey: userKeys.detail(userId),
queryFn: ({ signal }) => fetchUserById(userId, signal),
staleTime: 60 * 1000, // 1 минута
});
/**
* Конфигурация с трансформацией данных через select:
* Выбирает только форматированное имя и баланс в долларах.
*/
export const userBalanceSummaryOptions = (userId: string) =>
queryOptions({
...userDetailQueryOptions(userId),
// TData выводится как результат функции select
select: (user): { name: string; balanceUsd: string } => ({
name: user.fullName,
balanceUsd: `$${(user.balanceCents / 100).toFixed(2)}`,
}),
});При реализации мутации критически важно связать тип возвращаемого значения onMutate с третьим параметром onError:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { userKeys } from './userKeys';
import { UserDto, userDetailQueryOptions } from './userQueries';
export interface UpdateBalanceVariables {
readonly userId: string;
readonly deltaCents: number;
}
export interface MutationRollbackContext {
readonly previousUser: UserDto | undefined;
}
export function useUpdateUserBalance() {
const queryClient = useQueryClient();
return useMutation<UserDto, Error, UpdateBalanceVariables, MutationRollbackContext>({
mutationFn: async ({ userId, deltaCents }) => {
const res = await fetch(`/api/users/${userId}/balance`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ deltaCents }),
});
if (!res.ok) {
throw new Error(`[MutationError] Сбой обновления баланса: ${res.status}`);
}
return (await res.json()) as UserDto;
},
// 1. Оптимистичное обновление и фиксация снимка контекста
onMutate: async ({ userId, deltaCents }) => {
const queryKey = userKeys.detail(userId);
// Отменяем фоновые refetch-запросы, чтобы они не затерли оптимистичный UI
await queryClient.cancelQueries({ queryKey });
// Делаем снимок текущего состояния
const previousUser = queryClient.getQueryData<UserDto>(queryKey);
// Оптимистично записываем обновленный баланс
if (previousUser) {
queryClient.setQueryData<UserDto>(queryKey, {
...previousUser,
balanceCents: previousUser.balanceCents + deltaCents,
});
}
// Возвращаем строго типизированный контекст отката
return { previousUser };
},
// 2. Откат при возникновении сетевой или серверной ошибки
onError: (_err, { userId }, context) => {
if (context?.previousUser) {
queryClient.setQueryData(userKeys.detail(userId), context.previousUser);
}
},
// 3. Инвалидация через фабрику ключей по завершении
onSettled: (_data, _error, { userId }) => {
void queryClient.invalidateQueries({ queryKey: userKeys.detail(userId) });
},
});
}import { useInfiniteQuery } from '@tanstack/react-query';
import { userKeys } from './userKeys';
import { UserDto } from './userQueries';
export interface PaginatedUsersResponse {
readonly users: ReadonlyArray<UserDto>;
readonly nextCursor: string | null;
}
export function useUserInfiniteList(role?: 'admin' | 'member') {
return useInfiniteQuery<
PaginatedUsersResponse, // TQueryFnData
Error, // TError
PaginatedUsersResponse, // TData
readonly ['users', 'list', { role?: 'admin' | 'member' }], // TQueryKey
string | null // TPageParam
>({
queryKey: userKeys.list({ role }),
initialPageParam: null,
queryFn: async ({ pageParam, signal }) => {
const url = new URL('/api/users', window.location.origin);
if (role) url.searchParams.set('role', role);
if (pageParam) url.searchParams.set('cursor', pageParam);
const res = await fetch(url.toString(), { signal });
if (!res.ok) throw new Error('Ошибка загрузки списка');
return (await res.json()) as PaginatedUsersResponse;
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
getPreviousPageParam: () => null,
});
}Фабрика формирует неизменяемый кортеж ключа, предотвращая расхождение строковых префиксов при инвалидации.
Функция связывает ключ, сетевой промис и политику кэширования. TypeScript автоматически выводит типы данных и ошибок.
onMutate отменяет активные запросы, сохраняет снимок кэша в MutationRollbackContext и применяет локальный апдейт.
При сбое onError использует контекст для восстановления прежнего значения. onSettled инвалидирует срез кэша по фабрике.
import { QueryClient } from '@tanstack/react-query';
import { userKeys } from './userKeys';
export class EntityCacheManager {
constructor(private readonly queryClient: QueryClient) {}
/**
* Инвалидирует абсолютно все кэши, связанные с пользователями
*/
public async invalidateAllUsers(): Promise<void> {
await this.queryClient.invalidateQueries({
queryKey: userKeys.all,
});
}
/**
* Инвалидирует только списки пользователей, не трогая открытые карточки деталей
*/
public async invalidateUserLists(): Promise<void> {
await this.queryClient.invalidateQueries({
queryKey: userKeys.lists(),
});
}
/**
* Принудительно очищает детальную запись конкретного пользователя из памяти
*/
public removeUserDetail(userId: string): void {
this.queryClient.removeQueries({
queryKey: userKeys.detail(userId),
exact: true,
});
}
}Почему первым шагом внутри обработчика onMutate при оптимистичном обновлении обязательно должен вызываться await queryClient.cancelQueries({ queryKey })?
Если не указать as const при создании ключа, компилятор расширит литералы 'users' и 'detail' до общего типа string[]. Это сломает частичное сопоставление (Fuzzy matching) и автодополнение.
// ❌ АНТИПАТТЕРН: Тип расширяется до string[]
export const badKeys = {
all: ['users'], // string[]
};
// ✅ ПРАВИЛЬНО: Строгий литеральный кортеж
export const goodKeys = {
all: ['users'] as const, // readonly ['users']
};Если не объявить 4-й параметр в useMutation<TData, TError, TVariables, TContext>, TypeScript присвоит параметру context тип unknown. Попытка обратиться к context.previousUser в onError вызовет ошибку компиляции TS2339.
// ❌ АНТИПАТТЕРН: Контекст не указан, context имеет тип unknown
useMutation({
onMutate: () => ({ prev: 10 }),
onError: (err, vars, context) => {
// context.prev -> Ошибка TS2339: Property 'prev' does not exist on type 'unknown'
}
});
// ✅ ПРАВИЛЬНО: Явная типизация контекста в дженерике
useMutation<Data, Error, Vars, { prev: number }>({ ... });Если функция запроса не передает signal в fetch или axios, браузер не сможет оборвать сетевое соединение при размонтировании компонента или быстрой смене вкладок.
// ❌ АНТИПАТТЕРН: Запрос невозможно прервать
queryFn: () => fetch('/api/data').then(r => r.json());
// ✅ ПРАВИЛЬНО: Прокидывание signal
queryFn: ({ signal }) => fetch('/api/data', { signal }).then(r => r.json());Прямое изменение полей полученного из кэша объекта ломает ссылки в React Fiber и предотвращает уведомление подписчиков.
// ❌ АНТИПАТТЕРН: Прямая мутация ссылки кэша
const user = queryClient.getQueryData(['users', id]);
user.balance = 500; // ЗАПРЕЩЕНО: Нарушение иммутабельности!
// ✅ ПРАВИЛЬНО: Использование сеттера с новым объектом
queryClient.setQueryData(['users', id], (old: User | undefined) =>
old ? { ...old, balance: 500 } : undefined
);