Skip to content

QueryKey и кэш

TQueryKey — массив значений, который отвечает на вопрос: «какие именно данные лежат в этой записи кэша?»

ts
type TQueryKey = readonly unknown[]

Ключ не отправляется backend. Он существует только внутри Query Layer.

Простейший ключ

ts
["users"]

Такой ключ подходит только если в приложении существует один вариант данных users. На практике лучше описывать назначение точнее:

ts
["users", "list"]
["users", "details", "42"]

Как читать ключ

ts
["projects", "details", projectId]
ПозицияЗначениеСмысл
0"projects"Namespace предметной области.
1"details"Вид ресурса: список, детали, статистика.
2projectIdИдентификатор конкретного проекта.

Для projectId === "p-10" запись относится только к проекту p-10.

Главное правило

Все значения, которые меняют ответ queryFn или request, должны менять и queryKey.

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

ts
queryKey: () => ["users", "list"]
queryFn: ({ signal }) => getUsersApi({ role: this.role }, { signal })

При role === "admin" и role === "manager" ответы попадут в одну запись.

Правильно:

ts
queryKey: () => ["users", "list", { role: this.role }]
queryFn: ({ signal }) => getUsersApi({ role: this.role }, { signal })

Одинарные ключи

Одинарный ключ адресует один тип данных:

ts
["profile", "me"]

Он подходит для текущего профиля, если cache уже разделён по user/session через QueryRuntime. Без runtime при смене пользователя query нужно удалить или включить user id в ключ:

ts
["profile", "by-user", userId]

Пересекающиеся ключи

Ключи можно организовать иерархически:

ts
["projects"]
["projects", "list"]
["projects", "list", { status: "active" }]
["projects", "details", "p-10"]
["projects", "details", "p-11"]
["projects", "details", "p-10", "members"]

Префикс [projects] охватывает все данные проектов. Префикс ["projects", "details"] охватывает только карточки проектов. Полный ключ ["projects", "details", "p-10"] адресует одну карточку.

Это позволяет выбирать масштаб invalidation.

Точная и частичная invalidation

ts
// Только один проект.
{ queryKey: ["projects", "details", "p-10"], exact: true }

// Все project details с любым id.
{ queryKey: ["projects", "details"], exact: false }

// Все query области projects.
{ queryKey: ["projects"], exact: false }

Чем шире invalidation, тем больше запросов может повториться. После изменения названия одного проекта обычно достаточно инвалидировать его details и списки, в которых показано имя:

ts
invalidateQueries: [
  { queryKey: ["projects", "details", projectId], exact: true },
  { queryKey: ["projects", "list"], exact: false },
]

Объекты в ключе

Объект удобен для filters:

ts
["users", "list", { page: 1, role: "admin", search: "ann" }]

Query Layer хэширует ключ структурно. Новый объект с теми же данными даёт тот же hash:

ts
["users", { page: 1, role: "admin" }]
["users", { role: "admin", page: 1 }]

Поддерживаются primitives (включая BigInt и undefined), плотные массивы и plain objects с обычными enumerable data properties. При регистрации Query Layer сохраняет глубоко изолированный и замороженный snapshot ключа, поэтому последующее изменение исходного массива или объекта не повреждает identity уже созданной cache entry.

Date, RegExp, Map и Set намеренно не принимаются. Object.freeze() не замораживает их внутреннее состояние, поэтому такой key мог бы изменить hash после регистрации. Сначала приведите значение к неизменяемой форме:

ts
queryKey: () => [
  "events",
  {
    at: selectedDate.toISOString(),
    tags: [...selectedTags].sort(),
    filters: [...filters.entries()].sort(([left], [right]) => left.localeCompare(right)),
  },
]

Но в ключ нельзя помещать нестабильные или несериализуемые значения:

  • React-компоненты;
  • DOM-узлы;
  • функции;
  • случайные значения;
  • Date, RegExp, Map, Set и class instances;
  • sparse arrays, symbol-keyed или accessor properties;
  • access token и другие секреты.

Предпочитайте простые строки, числа, BigInt, boolean, null, массивы и plain objects. Функции, symbols, mutable built-ins, class instances и циклические ссылки отклоняются с ошибкой.

undefined и отсутствующее поле

Чтобы ключ был предсказуемым, нормализуйте необязательные filters:

ts
queryKey: () => [
  "orders",
  "list",
  {
    status: this.status ?? null,
    customerId: this.customerId ?? null,
  },
]

null явно говорит «фильтр отсутствует». Это проще анализировать в DevTools и логах, чем случайное смешение отсутствующих полей и undefined.

FetchStore

У FetchStore ключ строится из аргументов fetch:

ts
const store = new FetchStore<User, [string]>({
  queryKey: (userId) => ["users", "details", userId],
  request: (userId, config) => getUserByIdApi(userId, config),
})

await store.fetch("42")

Один аргумент "42" передаётся и в queryKey, и в request.

QueryStore

У QueryStore аргументы находятся в observable-полях:

ts
queryKey: () => ["users", "details", this.userId]
queryFn: ({ signal }) => getUserByIdApi(this.userId, { signal })

Когда this.userId меняется, MobX reaction вычисляет новый ключ.

InfiniteQueryStore

Cursor страницы в ключ не включается:

ts
queryKey: () => ["feed", "events", { category: this.category }]

Все страницы одной ленты образуют одну запись IInfiniteData. Cursor хранится в pageParams. А вот фильтр category создаёт другую ленту и входит в ключ.

Tenant и user

В простом приложении можно включить их в ключ вручную:

ts
["tenant", tenantId, "user", userId, "projects", "list"]

В QueryRuntime participant scope добавляет физический partition prefix сам. Remote продолжает использовать логический ключ:

ts
["projects", "list"]

Runtime отделит данные разных tenant, user и participant. Подробнее: Кэш и sharing в Module Federation.

Checklist ключа

Перед добавлением query ответьте:

  1. Какие данные описывает ключ?
  2. Все ли параметры запроса присутствуют в нём?
  3. Не содержит ли он token, пароль или PII без необходимости?
  4. Можно ли адресно инвалидировать одну сущность и список?
  5. Стабилен ли ключ между render?
  6. Не включён ли cursor infinite query?

Связанные API: FetchStore, QueryStore, InfiniteQueryStore.