Query keys и их структура

В основе работы библиотеки TanStack Query лежит система идентификации запросов через query keys. Каждый запрос в кеше должен иметь уникальный ключ, по которому библиотека определяет:

  • какие данные относятся к конкретному запросу;
  • когда использовать кеш;
  • какие запросы необходимо обновить;
  • какие данные инвалидировать;
  • какие запросы считаются одинаковыми;
  • какие подписчики должны получить обновление.

Query key — это не просто строка. Это структурированный идентификатор состояния запроса.

Библиотека строит всю архитектуру кеширования именно вокруг ключей.


Базовая форма query key

Наиболее простая форма:

useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers
})

Здесь:

['users']

— уникальный идентификатор запроса.

Хотя визуально это массив, внутри библиотеки ключ сериализуется в стабильное значение и используется как адрес кеша.


Почему используется массив

В ранних версиях React Query разрешались строковые ключи:

queryKey: 'users'

Современный подход в TanStack Query основан исключительно на массивах:

queryKey: ['users']

Причины:

1. Возможность создавать иерархию

['users']
['users', 1]
['users', 1, 'posts']

2. Гибкость параметров

['products', category, sort]

3. Частичная инвалидизация

queryClient.invalidateQueries({
    queryKey: ['users']
})

Инвалидируются:

['users']
['users', 1]
['users', 2]
['users', 5, 'posts']

Структура ключа

Обычно query key состоит из нескольких уровней.

Пример:

['posts', postId, 'comments']

Структура:

Элемент Назначение
'posts' тип сущности
postId идентификатор
'comments' вложенный ресурс

Простые ключи

Список данных

['users']

Один объект

['users', userId]

Вложенные данные

['users', userId, 'posts']

Параметризованные запросы

['products', category, sort]

Query keys как зависимость запроса

Query key напрямую влияет на жизненный цикл запроса.

Пример:

const result = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId)
})

Когда меняется:

userId

меняется и query key:

['user', 1]
['user', 2]

Для библиотеки это уже два разных запроса.

Следствия:

  • создаётся новый кеш;
  • выполняется новый запрос;
  • старые данные сохраняются отдельно;
  • подписчики переключаются на новый query.

Стабильность query key

Ключ должен быть:

  • детерминированным;
  • предсказуемым;
  • сериализуемым;
  • стабильным между рендерами.

Допустимые типы данных

Обычно используются:

['users']
['users', 1]
['users', 'active']
['products', { page: 1 }]

Допускаются:

  • строки;
  • числа;
  • boolean;
  • null;
  • plain object;
  • массивы.

Объекты внутри query key

Очень распространённый подход:

['products', {
    page: 1,
    sort: 'price',
    category: 'phones'
}]

Это особенно удобно при большом количестве параметров.


Как TanStack Query сравнивает ключи

Библиотека использует глубокое стабильное сравнение.

Пример:

['products', { page: 1 }]

и

['products', { page: 1 }]

считаются одинаковыми, даже если объекты созданы заново.


Важность порядка элементов

Порядок элементов имеет значение.

Разные ключи:

['users', 1]
['users', 2]

Разные ключи:

['products', 'phones', 'price']
['products', 'price', 'phones']

Важность структуры объектов

Порядок полей объекта обычно не влияет:

['products', {
    page: 1,
    sort: 'price'
}]

и

['products', {
    sort: 'price',
    page: 1
}]

считаются одинаковыми.


Нестабильные значения в query key

Плохая практика:

['users', new Date()]

или:

['users', Math.random()]

или:

['users', () => {}]

Проблемы:

  • постоянные cache miss;
  • бесконечные refetch;
  • невозможность повторного использования кеша;
  • утечки памяти.

Query key и cache identity

Ключ полностью определяет запись кеша.

Например:

['todos']

и

['todos', { completed: true }]

— две разные записи.

Каждая хранит:

  • собственные данные;
  • собственный статус;
  • собственное время stale;
  • собственные observers;
  • собственные retry.

Иерархия query keys

Одно из главных преимуществ системы.

Пример:

['users']
['users', 1]
['users', 1, 'posts']
['users', 1, 'comments']

Такой подход позволяет:

  • массово инвалидировать запросы;
  • группировать кеш;
  • строить предсказуемую структуру данных.

Частичная фильтрация query keys

Пример:

queryClient.invalidateQueries({
    queryKey: ['users']
})

Будут затронуты:

['users']
['users', 1]
['users', 2]
['users', 5, 'posts']

Но не:

['posts']

Exact matching

Иногда требуется инвалидировать только конкретный ключ.

queryClient.invalidateQueries({
    queryKey: ['users'],
    exact: true
})

Теперь инвалидируется только:

['users']

Плоские ключи против иерархических

Плохой вариант

['user-posts-comments']

Проблемы:

  • отсутствует структура;
  • невозможно частично фильтровать;
  • сложно поддерживать.

Хороший вариант

['users', userId, 'posts', 'comments']

Преимущества:

  • логичная структура;
  • удобная инвалидизация;
  • масштабируемость;
  • читаемость.

Query key factory

В крупных проектах ключи централизуют.


Проблема хаотичных ключей

Без единого подхода появляются:

['user']
['users']
['userData']
['profile']

Это приводит к:

  • дублированию кеша;
  • ошибкам инвалидизации;
  • сложной поддержке.

Фабрика ключей

Пример:

export const userKeys = {
    all: ['users'],

    lists: () => [...userKeys.all, 'list'],

    list: (filters) => [
        ...userKeys.lists(),
        filters
    ],

    details: () => [...userKeys.all, 'detail'],

    detail: (id) => [
        ...userKeys.details(),
        id
    ]
}

Использование:

useQuery({
    queryKey: userKeys.detail(5),
    queryFn: () => fetchUser(5)
})

Преимущества фабрики ключей

Централизация

Все ключи находятся в одном месте.


Предсказуемость

Структура становится стандартизированной.


Безопасность

Меньше вероятности опечаток:

['usrs']

Удобная инвалидизация

queryClient.invalidateQueries({
    queryKey: userKeys.all
})

Namespace-подход

Часто ключ строится как namespace.

Пример:

['dashboard']
['dashboard', 'stats']
['dashboard', 'charts']

Или:

['admin', 'users']
['admin', 'settings']

Это особенно полезно в больших приложениях.


Query keys и pagination

Пагинация всегда должна быть частью ключа.

Неправильно:

['posts']

Правильно:

['posts', page]

Иначе страницы будут перезаписывать кеш друг друга.


Query keys и filters

Фильтры тоже должны входить в ключ.

Неправильно:

['products']

Правильно:

['products', filters]

Иначе библиотека не поймёт, что данные изменились.


Query keys и сортировка

Сортировка должна быть частью cache identity.

['products', {
    sort: 'price'
}]

Query keys и infinite queries

Infinite query также зависит от ключа.

useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: fetchFeed
})

Если фильтр изменится:

['feed', category]

создастся новая цепочка страниц.


Query keys и prefetch

Prefetch использует те же ключи.

queryClient.prefetchQuery({
    queryKey: ['users', 1],
    queryFn: () => fetchUser(1)
})

Позже:

useQuery({
    queryKey: ['users', 1],
    queryFn: () => fetchUser(1)
})

использует уже готовый кеш.


Query keys и hydration

При SSR ключи становятся особенно важными.

Сервер:

['posts', 1]

Клиент:

['posts', 1]

Ключи должны совпадать полностью.

Иначе hydration не сможет связать серверные данные с клиентским кешем.


Query keys и invalidateQueries

Инвалидизация строится вокруг ключей.

Пример:

queryClient.invalidateQueries({
    queryKey: ['posts']
})

После мутации:

await createPost(data)

можно обновить все связанные запросы.


Query keys и refetchQueries

queryClient.refetchQueries({
    queryKey: ['notifications']
})

Библиотека найдёт соответствующие queries по ключам.


Query keys и removeQueries

queryClient.removeQueries({
    queryKey: ['drafts']
})

Записи кеша удаляются по ключу.


Query keys и setQueryData

Ключ определяет, какие данные обновлять вручную.

queryClient.setQueryData(
    ['user', 5],
    updater
)

Query keys и optimistic updates

Optimistic update работает только при правильной структуре ключей.

Пример:

queryClient.setQueryData(
    ['todos'],
    old => [...old, optimisticTodo]
)

Антипаттерн: слишком общий ключ

Плохо:

['data']

Проблемы:

  • смешивание сущностей;
  • сложная инвалидизация;
  • конфликт кеша.

Антипаттерн: слишком детализированный ключ

Плохо:

[
    'users',
    userId,
    Date.now()
]

Каждый рендер создаёт новый query.


Антипаттерн: несогласованная структура

Плохо:

['users', id]
['user', id]
['users-detail', id]

Следствие — хаос в кеше.


Рекомендуемая структура

Обычно используется такой порядок:

[
    resource,
    scope,
    params
]

Пример:

[
    'products',
    'list',
    {
        page: 1,
        sort: 'price'
    }
]

Практический пример структуры

export const productKeys = {
    all: ['products'],

    lists: () => [
        ...productKeys.all,
        'list'
    ],

    list: (filters) => [
        ...productKeys.lists(),
        filters
    ],

    details: () => [
        ...productKeys.all,
        'detail'
    ],

    detail: (id) => [
        ...productKeys.details(),
        id
    ]
}

Масштабирование архитектуры ключей

В больших приложениях query keys становятся частью общей архитектуры.

Обычно определяются:

  • namespace;
  • правила именования;
  • структура параметров;
  • factories;
  • правила инвалидизации;
  • стандарты вложенности.

Типизация query keys в TypeScript

Часто используется readonly tuple:

const userKey = ['users', id] as const

Это помогает:

  • улучшить autocomplete;
  • предотвратить ошибки;
  • усилить типизацию queryClient API.

Итоговая модель мышления

Query key в TanStack Query — это:

  • идентификатор кеша;
  • описание состояния запроса;
  • зависимость запроса;
  • инструмент группировки;
  • механизм инвалидизации;
  • архитектурный namespace;
  • основа всей системы серверного состояния.

Чем лучше организована структура query keys, тем:

  • предсказуемее кеш;
  • проще поддержка;
  • легче масштабирование;
  • эффективнее повторное использование данных;
  • стабильнее работа приложения.