У масштабних вебзастосунках TanStack Query (раніше React Query) v5 виконує роль розподіленого асинхронного серверного кешу. Центральним елементом цього шару кешування є Query Key (ключ запиту) — серіалізовний масив літералів, ідентифікаторів та об'єктів фільтрації, що виступає первинним ключем для дедуплікації, збереження та інвалідації даних.
У незрілих архітектурах ключі запитів часто оголошуються як розрізнені рядкові масиви по всьому коду компонентів:
// Крихкий код: одруківка в одному символі ламає інвалідацію кешу в рантаймі!
useQuery({ queryKey: ['users', id, 'detail'] });
queryClient.invalidateQueries({ queryKey: ['user', id, 'details'] }); // Тихий збій!Для усунення розсинхронізації та дрифту типів застосовується архітектурний патерн Query Key Factory (фабрика ключів кешу) у поєднанні з утилітою queryOptions(), представленою в TanStack Query v5. Цей підхід забезпечує наскрізну типізацію: від опису ендпоінта до оптимістичних мутацій зі строго типізованим контекстом відкату (Rollback Context).
+-------------------------------------------------------------------------+
| АРХІТЕКТУРА НАСКРІЗНОЇ ТИПІЗАЦІЇ КЕШУ В V5 |
+-------------------------------------------------------------------------+
| [Query Key Factory: userKeys.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'] автоматично скидає всі вкладені списки та детальні дані!MutationRollbackContext при збої сервера.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 хвилина
});
/**
* Конфігурація із селектором:
* Обирає лише форматоване ім'я та баланс у доларах.
*/
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 з аргументом context у onError:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { userKeys } from './userKeys';
import { UserDto } 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);
// Скасовуємо фонові запити, щоб вони не перезаписали локальний стан
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,
});
}Фабрика формує незмінний кортеж, унеможливлюючи розсинхронізацію рядкових префіксів під час інвалідації.
Об'єднує ключ, мережевий проміс та політику кешування в ізоморфний об'єкт із виведенням типів даних.
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,
});
}
}Якщо не вказати as const при створенні ключів, TypeScript розширить літерали '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 викличе помилку компіляції.
// ❌ АНТИПАТЕРН: Контекст не типізований, має тип 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());
// ✅ ПРАВИЛЬНО: Прокидання AbortSignal
queryFn: ({ signal }) => fetch('/api/data', { signal }).then(r => r.json());Зміна властивостей об'єкта, отриманого з getQueryData, порушує контроль посилань у React Fiber і блокує оновлення підписаних компонентів.
// ❌ АНТИПАТЕРН: Пряма мутація посилання кешу
const user = queryClient.getQueryData(['users', id]);
user.balance = 500; // ЗАБОРОНЕНО: Порушення імутабельності!
// ✅ ПРАВИЛЬНО: Використання функціонального сеттера
queryClient.setQueryData(['users', id], (old: User | undefined) =>
old ? { ...old, balance: 500 } : undefined
);Опишіть ієрархічний об'єкт із методами, що повертають незмінні кортежі з as const.
Сконфігуруйте параметри запитів із типізованими queryFn, staleTime та селекторами.
Передавайте queryOptions у prefetchQuery на сервері для усунення водоспадних запитів.
Налаштуйте useMutation із типізованим TContext для безпечного відкату при помилках.
Використовуйте методи фабрики ключів у колбеках onSettled для передбачуваного скидання кешу.