Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Строгая типизация инвалидации кэша в TanStack Query: от фабрик ключей к безопасному рефетчу

Тимофей Ищенко

Коротко: Руководство по строгой типизации инвалидации кэша в TanStack Query: паттерн фабрики ключей, точечный сброс с exact, настройка refetchType и TypeScript

Управление серверным состоянием на клиенте неизбежно упирается в синхронизацию: после выполнения мутации данные в кэше устаревают, и приложение должно оперативно подтянуть свежее состояние. В TanStack Query (ранее React Query) за эту задачу отвечает метод queryClient.invalidateQueries.

На начальном этапе проектов ключи кэша часто объявляют инлайново в виде простых массивов строк. Однако по мере роста кодовой базы такой подход превращается в источник скрытых ошибок: опечатки в строковых литералах, нарушение порядка параметров фильтрации и непреднамеренный сброс соседних веток кэша из-за префиксного сопоставления. Разберем, как выстроить масштабируемую архитектуру фабрик ключей в TypeScript и сделать инвалидацию кэша на 100% предсказуемой и типобезопасной.


Почему инвалидация кэша в TanStack Query часто ломается

Главная причина проблем с кэшем в больших приложениях — отсутствие единого контракта между местом объявления запроса (useQuery) и местом его инвалидации (useMutation или обработчики событий).

// Где-то в TodoList.tsx
useQuery({
  queryKey: ['todos', 'list', { status: 'completed', page: 1 }],
  queryFn: fetchTodos,
});

// Где-то в CreateTodoModal.tsx
queryClient.invalidateQueries({
  queryKey: ['todo', 'list'], // Опечатка: 'todo' вместо 'todos'. Кэш не обновится!
});

Компилятор TypeScript здесь бессилен, поскольку тип queryKey по умолчанию выводится как unknown[] или readonly unknown[].

Проблема «магических строк» и неявного prefix-matching

По умолчанию алгоритм сопоставления ключей в TanStack Query использует префиксный матчинг (partial matching). Это означает, что переданный массив сравнивается с началом ключей в кэше:

// Вызов:
queryClient.invalidateQueries({ queryKey: ['users'] });

// Инвалидирует все следующие запросы:
// ['users']
// ['users', 'list']
// ['users', 'detail', '123']
// ['users', 'settings', { theme: 'dark' }]

Если разработчик хотел обновить только метаданные списка пользователей, но случайно передал общий префикс, библиотека инвалидирует все открытые экраны профилей и настройки, порождая лавину паразитных сетевых запросов.

Что на самом деле делает invalidateQueries: stale vs refetch

Вызов invalidateQueries не стирает данные из оперативной памяти немедленно. Происходит два параллельных действия:

  1. Запросы, удовлетворяющие фильтру, переводятся в статус isStale: true, независимо от заданного для них значения staleTime.
  2. Если у запроса есть активные наблюдатели (компоненты, смонтированные на экране прямо сейчас и подписанные через useQuery), библиотека инициирует фоновый повторный запрос (refetch).

Если компонент размонтирован, фоновый запрос не отправляется. Данные просто помечаются устаревшими и будут автоматически перезапрошены сетью только тогда, когда пользователь снова откроет соответствующий экран. Пользователь при этом не видит состояния загрузки с нуля (isLoading: true), так как в кэше сохраняются предыдущие данные (isRefetching: true).


Анатомия Query Keys: строим строгую иерархию

Для предотвращения хаоса в ключах запросов их структура должна строиться от общего к частному:

  1. Сущность / Ресурс (scope): например, 'users', 'projects'.
  2. Тип представления (entity type): 'list', 'detail', 'infinite'.
  3. Параметры и фильтры: объекты с параметрами сортировки, пагинации или конкретные ID.
['projects']                                // Весь скоуп проектов
├── ['projects', 'list']                    // Все списки проектов
│   ├── ['projects', 'list', { page: 1 }]   // Конкретная страница списка
│   └── ['projects', 'list', { page: 2 }]
└── ['projects', 'detail']                  // Все детализированные представления
    ├── ['projects', 'detail', 'uuid-1']
    └── ['projects', 'detail', 'uuid-2']

Структурирование ключей кортежами через as const

Чтобы компилятор не расширял типы массивов до string[], каждый ключ необходимо фиксировать с помощью утверждения as const. Это превращает массив в неизменяемый кортеж (readonly tuple) с литеральными типами:

// Тип: readonly ['users', 'detail', string]
export const userDetailKey = (id: string) => ['users', 'detail', id] as const;

Паттерн Query Key Factory (Фабрика ключей)

Шаблон Query Key Factory централизует все ключи конкретного домена в едином объекте. Это исключает дублирование строковых литералов и гарантирует консистентность типов аргументов.

// features/users/query-keys.ts
export interface UserListParams {
  role?: 'admin' | 'member';
  page: number;
  limit: number;
}

export const usersKeys = {
  all: ['users'] as const,
  lists: () => [...usersKeys.all, 'list'] as const,
  list: (params: UserListParams) => [...usersKeys.lists(), params] as const,
  details: () => [...usersKeys.all, 'detail'] as const,
  detail: (id: string) => [...usersKeys.details(), id] as const,
};

Благодаря такой структуре мы получаем готовую иерархию:

  • usersKeys.all сбрасывает абсолютно весь домен пользователей.
  • usersKeys.lists() инвалидирует все вариации списков (независимо от фильтров и страниц).
  • usersKeys.list({ page: 1, limit: 10 }) затрагивает только конкретный запрос.
  • usersKeys.detail(id) обновляет данные конкретного пользователя.

Типобезопасный вызов invalidateQueries на практике

В актуальных версиях TanStack Query (v5) метод invalidateQueries принимает единый объект настроек InvalidateQueryFilters.

Точечный сброс с exact: true против групповой инвалидации

Флаг exact отключает префиксное сопоставление и требует полного совпадения длины массива и всех его элементов:

import { useQueryClient } from '@tanstack/react-query';
import { usersKeys } from './query-keys';

const queryClient = useQueryClient();

// 1. Групповая инвалидация: обновит ВСЕ списки с любыми параметрами
await queryClient.invalidateQueries({
  queryKey: usersKeys.lists(),
  exact: false, // по умолчанию false
});

// 2. Строгая инвалидация: обновит ТОЛЬКО общий список без параметров,
// если такой был зарегистрирован как ['users', 'list']
await queryClient.invalidateQueries({
  queryKey: usersKeys.lists(),
  exact: true,
});

Если требуется инвалидировать ровно одну страницу с конкретным набором параметров после локального редактирования, используйте exact: true:

await queryClient.invalidateQueries({
  queryKey: usersKeys.list({ page: 1, limit: 20, role: 'admin' }),
  exact: true,
});

Управление поведением рефетча с refetchType

Опция refetchType определяет, какие запросы из числа совпавших по ключу должны немедленно отправить сетевой запрос:

  • 'active' (по умолчанию) — перезапрашивает только те запросы, на которые прямо сейчас подписаны смонтированные компоненты.
  • 'inactive' — перезапрашивает только неактивные запросы.
  • 'all' — принудительно перезапрашивает и активные, и неактивные запросы.
  • 'none' — только помечает данные как stale, не инициируя сетевых вызовов вообще.

Пример отложенной инвалидации для экономии трафика:

// Пользователь изменил аватар в модальном окне.
// Нет необходимости срочно перезапрашивать весь профиль, если вкладка скрыта.
await queryClient.invalidateQueries({
  queryKey: usersKeys.details(),
  refetchType: 'none', // Данные станут stale, но refetch произойдет только при открытии экрана
});

Фильтрация через predicate: строгая типизация предиката

Когда стандартного сопоставления по префиксу недостаточно, используется функция predicate. Она выполняется для каждого элемента кэша:

import { Query } from '@tanstack/react-query';

await queryClient.invalidateQueries({
  predicate: (query: Query) => {
    const key = query.queryKey;
    // Проверяем, относится ли запрос к спискам пользователей
    const isUserList =
      Array.isArray(key) &&
      key[0] === 'users' &&
      key[1] === 'list' &&
      typeof key[2] === 'object' &&
      key[2] !== null;

    if (!isUserList) return false;

    // Инвалидируем только списки, где лимит больше 50
    const params = key[2] as UserListParams;
    return params.limit > 50;
  },
});

Продвинутые архитектурные паттерны типизации

Связывание useMutation с типами фабрики ключей

Чтобы защитить мутации от ошибок типизации, инкапсулируйте логику вызова invalidateQueries внутри кастомных хуков мутаций:

// features/users/use-update-user.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { usersKeys } from './query-keys';
import { updateUserApi, type UpdateUserDto, type UserDto } from './api';

export const useUpdateUser = (userId: string) => {
  const queryClient = useQueryClient();

  return useMutation<UserDto, Error, UpdateUserDto>({
    mutationFn: (dto) => updateUserApi(userId, dto),
    onSuccess: () => {
      // 1. Обновляем детали конкретного пользователя
      queryClient.invalidateQueries({
        queryKey: usersKeys.detail(userId),
      });

      // 2. Инвалидируем все списки, так как имя или роль могли измениться
      queryClient.invalidateQueries({
        queryKey: usersKeys.lists(),
      });
    },
  });
};

Создание типизированных хелперов инвалидации

Для масштабных корпоративных приложений можно создать обертку над QueryClient, которая разрешает передавать в invalidateQueries только ключи, сгенерированные фабриками:

import { QueryClient, InvalidateQueryFilters } from '@tanstack/react-query';

// Тип-утилита для извлечения типов допустимых ключей
type ValidQueryKey = 
  | typeof usersKeys.all
  | ReturnType<typeof usersKeys.lists>
  | ReturnType<typeof usersKeys.list>
  | ReturnType<typeof usersKeys.details>
  | ReturnType<typeof usersKeys.detail>;

interface SafeInvalidateOptions extends Omit<InvalidateQueryFilters, 'queryKey'> {
  queryKey: ValidQueryKey;
}

export function invalidateStrict(
  queryClient: QueryClient,
  options: SafeInvalidateOptions
) {
  return queryClient.invalidateQueries(options);
}

Попытка передать произвольный строковый массив ['custom_key'] в invalidateStrict вызовет ошибку на этапе компиляции.


Чек-лист: как избежать регрессий кэша в больших проектах

  • [ ] Никаких сырых строк в компонентах: Запретите передачу литералов вроде ['todos'] напрямую в хуки. Используйте линтер или строгие правила code review.
  • [ ] Один домен — один файл ключей: Храните фабрику ключей рядом с API-слоем соответствующей фичи (entities/user/api/keys.ts).
  • [ ] Фиксация типов через as const: Каждая функция фабрики должна возвращать неизменяемый кортеж.
  • [ ] Осознанное использование exact: true: Всегда проверяйте, должен ли сброситься весь дочерний кэш или только запрос с конкретным набором параметров.
  • [ ] Группировка по префиксу: Размещайте статичные идентификаторы ('list', 'detail') перед объектами параметров и динамическими ID.

Часто задаваемые вопросы (FAQ)

В чем разница между invalidateQueries и resetQueries?

invalidateQueries переводит запросы в статус stale и запускает фоновый refetch для активных компонентов, сохраняя существующие данные на экране до получения ответа (UI не мигает лоадером). resetQueries полностью сбрасывает состояние кэша до его начального значения (initialData или undefined), возвращая компонент в состояние isLoading.

Зачем указывать exact: true при инвалидации?

По умолчанию TanStack Query инвалидирует все запросы, чей ключ начинается с указанного префикса. Флаг exact: true заставляет библиотеку проверять полное равенство элементов и длины массива ключа, исключая затрагивание дочерних веток.

Как связать параметры фабрики ключей со схемами валидации (Zod/OpenAPI)?

Типы аргументов функций фабрики (например, UserListParams) следует выводить напрямую из схем валидации (через z.infer<typeof schema>) или импортировать из автогенерированных контрактов OpenAPI/Swagger. Это гарантирует синхронизацию типов кэша при изменении схемы бэкенда.

Почему refetchType: 'none' полезен при пакетных мутациях?

Если выполняется серия фоновых операций или пользователь изменяет второстепенные настройки, вызов инвалидации с refetchType: 'none' помечает данные устаревшими без создания паразитной нагрузки на API. Данные будут запрошены только тогда, когда пользователь решит перейти на соответствующий экран.

Можно ли передавать в queryKey мутабельные объекты?

TanStack Query выполняет детерминированное хэширование объектов внутри ключа (порядок ключей в объекте не имеет значения: { a: 1, b: 2 } эквивалентно { b: 2, a: 1 }). Однако объекты должны быть чистыми структурами данных без циклических ссылок и методов классов.


Выводы

Инвалидация кэша перестает быть источником багов, если превратить структуру ключей в строгий контракт. Использование паттерна Query Key Factory в связке с возможностями TypeScript (as const, кортежи) позволяет:

  1. Полностью устранить опечатки и потерю параметров запроса при рефетче.
  2. Четко разграничивать точечную и групповую инвалидацию за счет осознанного использования exact и refetchType.
  3. Упростить рефакторинг и поддержку API-слоя в масштабируемых React-приложениях.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также