Dev Highlights

Доки по разработке

This project is maintained by teniryte

3. Query Keys и кеш

queryKey — это идентификатор записи в кешe. Ключ определяет:

Базовые правила ключей

  1. Используйте массивы (['todos', userId]). Это встроенный стандарт TanStack Query.
  2. Первый элемент — «пространство имён» (например, 'todos'), следующие — параметры.
  3. Не передавайте функции или объекты без стабильной сериализации.
  4. Любой undefined внутри ключа делает запрос уникальным: ['user', undefined]['user'].
  5. Фиксируйте ключ через as const, чтобы TypeScript не «расплющивал» кортеж.
  6. Стабилизируйте сложные аргументы (useMemo / useStableValue), иначе при каждом рендере будет новый ключ и новый кеш.
const filters = useMemo(
  () => ({ status, orderBy }),
  [status, orderBy],
);

const queryKey = ['todos', filters] as const;
const query = useQuery({ queryKey, queryFn: () => fetchTodos(filters) });

Лайфхак: если нужно временно отключить часть ключа, заведите «пустышку» (null) вместо undefined, чтобы позже одинаковые ключи продолжали совпадать.

Типизация ключей

const todoKeys = {
  all: ['todos'] as const,
  detail: (id: string) => ['todos', 'detail', id] as const,
  list: (filters: TodoFilters) => ['todos', 'list', filters] as const,
} satisfies Record<string, (...args: any[]) => readonly QueryKey>;

Такой объект:

const todoQueryOptions = (userId: string) => ({
  queryKey: ['todos', { userId }],
  queryFn: () => fetchTodos(userId),
});

Частичные совпадения

queryClient.invalidateQueries({ queryKey: ['todos'] }) освежит все запросы, ключи которых начинаются с 'todos': например, ['todos'], ['todos', { status: 'done' }].

Используйте объект exact: true, если нужно затронуть только конкретный ключ.

await queryClient.invalidateQueries({
  queryKey: ['todos', { userId }],
  exact: true,
});

Дополнительные фильтры:

await queryClient.invalidateQueries({
  queryKey: todoKeys.list({ status: 'done' }),
  predicate: q => q.getObserversCount() > 0, // только видимые экраны
});

Совет: при массовой инвалидации на сервере используйте prefetchQuery сразу после invalidateQueries, чтобы прогреть кеш до входа пользователя на страницу SSR/SSG.

Фабрики опций

С v5 рекомендуется описывать ключ и настройки в одной функции:

export const userQuery = (id: string) => ({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
  staleTime: 10 * 60_000,
});

// reuse
const { data } = useQuery(userQuery(id));
await queryClient.prefetchQuery(userQuery(id));
await queryClient.ensureQueryData(userQuery(id)); // безопасно для SSR/потокового рендера

Это исключает рассинхронизацию при инвалидации, префетче и SSR.

Стратегии именования

export const projectKeys = {
  all: () => ['projects'] as const,
  detail: (projectId: string) => [...projectKeys.all(), projectId] as const,
  tasks: (projectId: string, filter: TaskFilter) =>
    [...projectKeys.detail(projectId), 'tasks', filter] as const,
};

Лайфхак: когда нужно «разорвать» кэш-цепочку (например, после logout), используйте числовой версиионинг ['user', sessionVersion]. При смене версии вся иерархия ключей станет новой.

Доступ к кешу

queryClient.getQueryData(['todos', { userId }]);
queryClient.setQueryData(['todos', { userId }], updater);
queryClient.removeQueries({ queryKey: ['drafts'] });

getQueriesData(['todos']) возвращает массив пар [queryKey, data] для всех соответствий — удобно для массовых апдейтов.

Другие полезные методы:

const optimisticId = crypto.randomUUID();

queryClient.setQueryData(todoKeys.list(filters), old =>
  old?.map(todo => (todo.id === optimisticId ? { ...todo, title } : todo)),
);

queryClient.invalidateQueries({ queryKey: todoKeys.list(filters) }); // мягкий sync

Тонкость: setQueryData не запускает queryFn, поэтому обязательно инвалидируйте или вручную синхронизируйте сервер, иначе рискуете остаться с «фантомными» данными.

Кастомный queryKeyHashFn

По умолчанию ключ сериализуется в JSON-строку. Если вам нужно поддержать, например, Map, определите хэш-функцию:

const queryClient = new QueryClient({
  queryKeyHashFn: key => superjson.stringify(key),
});

Используйте осторожно: разные функции хэша между вкладками разрушат персистентность.

Дополнительно:

Диагностика и best practices

Частые лайфхаки

Примеры живых кейсов

// 1. Таблица с пагинацией + фильтрами
const tableQuery = (params: TableParams) => ({
  queryKey: ['table', 'list', params] as const,
  queryFn: () => fetchTable(params),
  placeholderData: keepPreviousData, // плавная смена страниц
});

// 2. Поддержка offline-first
queryClient.setDefaultOptions({
  queries: {
    gcTime: 1000 * 60 * 60, // 1h
    structuralSharing: true,
  },
});

// 3. Мгновенный локальный поиск по уже загруженному кешу
const cachedTodos = queryClient.getQueriesData({
  queryKey: todoKeys.list({}),
});