Тема
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" | Вид ресурса: список, детали, статистика. |
| 2 | projectId | Идентификатор конкретного проекта. |
Для 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 ответьте:
- Какие данные описывает ключ?
- Все ли параметры запроса присутствуют в нём?
- Не содержит ли он token, пароль или PII без необходимости?
- Можно ли адресно инвалидировать одну сущность и список?
- Стабилен ли ключ между render?
- Не включён ли cursor infinite query?
Связанные API: FetchStore, QueryStore, InfiniteQueryStore.