В современных React-приложениях более 80% данных — это не локальное состояние интерфейса (открыто ли модальное окно или выбран ли таб), а удаленный снимок базы данных, полученный по сети (Server State). Попытка синхронизировать серверные данные через Redux или Zustand приводит к сотням строк шаблонных редьюсеров. Промышленный стандарт — библиотека TanStack Query v5 в связке с паттерном Query Key Factory и оптимистичными обновлениями Optimistic UI.
['user', id], а в мутации вызывает queryClient.invalidateQueries({ queryKey: ['users', id] }) (добавив лишнюю букву 's'), кэш не инвалидируется, и пользователь продолжает видеть устаревшие данные.1. Пользователь нажимает "Добавить комментарий"
│
▼
2. onMutate() перехватывает событие:
- Отменяет активные запросы: cancelQueries(['comments', postId])
- Создает снимок кэша: previousComments = getQueryData(...)
- Оптимистично пишет в кэш: setQueryData(..., draftComment)
│
▼
3. UI мгновенно отображает комментарий (0 мс задержки)
│
▼ Фоновый HTTP POST-запрос на сервер
┌──────────────┴──────────────┐
▼ ▼
[ 201 Created ] [ 500 Network Error ]
Успех сервера! Сбой сети или сервера!
│ │
▼ ▼
4. onSuccess / onSettled: 4. onError():
Инвалидация ключа Откат к снимку:
для точной сверки setQueryData(..., previousComments)
с базой данных Уведомление об ошибкеОпределение строгого иерархического объекта postsKeys с методами all, lists, details для автодополнения и валидации TypeScript.
Создание хука с настройкой staleTime, select-трансформации данных и обработки пустого состояния.
Написание useMutation с логикой onMutate (снимок), onError (откат) и onSettled (инвалидация ключей).
Настройка выборки данных с курсорами (getNextPageParam) и интеграция с триггером IntersectionObserver.
Упреждающая загрузка деталей статьи через queryClient.prefetchQuery для нулевой задержки при клике.
В чем главное преимущество организации ключей кэша через фабрику объектов (Query Key Factory) по сравнению с ручной передачей массивов строк queryKey: ['posts', id] в компонентах?
Хук сопоставляет ключ postsKeys.detail(id) с хэш-таблицей в памяти. Если данные свежие (fresh), они возвращаются мгновенно.
Полное отсутствие лишних сетевых вызовов при повторном монтировании страницы.
Если срок staleTime истек, компонент сначала показывает старые данные (stale), а в фоне отправляет запрос для обновления.
Пользователь не видит белого экрана или мерцающих спиннеров.
При отправке изменений кэш мгновенно обновляется оптимистичным объектом. Пользователь видит результат за 0 мс.
Сохраненный контекст context.previousData гарантирует безопасный откат при сбое.
После успешной записи вызывается invalidateQueries, а неактивные данные автоматически удаляются из памяти сборщиком мусора gcTime.
Оперативная память браузера остается чистой даже при длительных сессиях.
/**
* Иерархическая фабрика ключей TanStack Query.
* Исключает строковые опечатки и обеспечивает гранулярную инвалидацию.
*/
export const postsKeys = {
all: ['posts'] as const,
lists: () => [...postsKeys.all, 'list'] as const,
list: (filters: { tag?: string; search?: string }) => [...postsKeys.lists(), filters] as const,
infinite: (filters: { tag?: string }) => [...postsKeys.all, 'infinite', filters] as const,
details: () => [...postsKeys.all, 'detail'] as const,
detail: (id: string) => [...postsKeys.details(), id] as const,
};import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import { postsKeys } from './posts.keys';
export interface Post {
id: string;
title: string;
content: string;
likesCount: number;
}
interface PostsResponse {
items: Post[];
nextCursor: number | null;
}
async function fetchPostsPage({ pageParam = 0 }: { pageParam?: number }): Promise<PostsResponse> {
const response = await fetch(`/api/posts?cursor=${pageParam}&limit=10`);
if (!response.ok) {
throw new Error('Ошибка при загрузке постов');
}
return response.json();
}
/**
* Бесконечная лента постов с поддержкой курсоров
*/
export function useInfinitePosts(tag?: string) {
return useInfiniteQuery({
queryKey: postsKeys.infinite({ tag }),
queryFn: ({ pageParam }) => fetchPostsPage({ pageParam: pageParam as number }),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
staleTime: 1000 * 60 * 2, // 2 минуты
});
}
/**
* Получение отдельной статьи по ID
*/
export function usePost(id: string) {
return useQuery({
queryKey: postsKeys.detail(id),
queryFn: async (): Promise<Post> => {
const res = await fetch(`/api/posts/${id}`);
if (!res.ok) throw new Error('Пост не найден');
return res.json();
},
enabled: Boolean(id),
staleTime: 1000 * 60 * 5,
});
}import { useMutation, useQueryClient } from '@tanstack/react-query';
import { postsKeys } from './posts.keys';
import { Post } from './use-posts';
interface MutationContext {
previousPost?: Post;
}
export function useLikePost(postId: string) {
const queryClient = useQueryClient();
return useMutation<Post, Error, void, MutationContext>({
mutationFn: async () => {
const response = await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
if (!response.ok) {
throw new Error('Не удалось поставить лайк');
}
return response.json();
},
// 1. Срабатывает мгновенно до отправки запроса в сеть
onMutate: async () => {
// Отменяем исходящие повторные выборки для этого поста, чтобы они не перезаписали оптимистичный UI
await queryClient.cancelQueries({ queryKey: postsKeys.detail(postId) });
// Сохраняем снимок текущего состояния поста
const previousPost = queryClient.getQueryData<Post>(postsKeys.detail(postId));
// Оптимистично обновляем кэш
if (previousPost) {
queryClient.setQueryData<Post>(postsKeys.detail(postId), {
...previousPost,
likesCount: previousPost.likesCount + 1,
});
}
// Возвращаем контекст со снимком для функции onError
return { previousPost };
},
// 2. В случае сбоя сети откатываемся к сохраненному снимку
onError: (_err, _variables, context) => {
if (context?.previousPost) {
queryClient.setQueryData(postsKeys.detail(postId), context.previousPost);
}
},
// 3. После завершения (успешного или ошибочного) инвалидируем ключ для синхронизации
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: postsKeys.detail(postId) });
},
});
}Если не вызвать await queryClient.cancelQueries({ queryKey }) в начале onMutate, ранее запущенный фоновый refetch-запрос может разрешиться посреди мутации и перезаписать оптимистичные данные старым серверным ответом.
Если функция getNextPageParam возвращает null вместо undefined в TanStack Query v5, хук может ошибочно посчитать, что следующая страница существует, и попытаться выполнить запрос с pageParam = null. Всегда возвращайте строго undefined, если страниц больше нет.
Вызов oldData.push(newItem) напрямую мутирует объект кэша в памяти в обход иммутабельности React. Это ломает отслеживание изменений и предотвращает ререндер компонентов. Всегда возвращайте новый массив: [...oldData, newItem].
Значение по умолчанию staleTime: 0 означает, что данные считаются устаревшими сразу после получения. Если компонент часто размонтируется или пользователь часто переключает вкладки, приложение будет спамить сервер сотнями дублирующих запросов. Устанавливайте разумный staleTime (например, 1–5 минут).
Пользователь нажал кнопку 'Удалить комментарий'. Компонент оптимистично удалил строку из таблицы. Сервер ответил ошибкой 503 Service Unavailable. Что должно произойти в функции onError() мутации?