В библиотеке TanStack Query ключ запроса (queryKey)
является центральным механизмом идентификации данных в кеше. Каждый
запрос сохраняется и извлекается по уникальному ключу. От качества
проектирования ключей напрямую зависит:
В JavaScript ключи являются динамическими и могут формироваться
практически любым способом. Однако при использовании TypeScript
появляется возможность строго типизировать структуру
queryKey, что значительно уменьшает количество ошибок.
Наиболее распространённый формат:
['users']
['users', userId]
['users', userId, 'posts']
['posts', { page: 1, limit: 20 }]
TanStack Query воспринимает ключ как сериализуемую структуру данных.
На практике ключ почти всегда представляет массив.
Без строгой типизации возникают типичные ошибки:
useQuery({
queryKey: ['user', id],
})
В другом месте:
queryClient.invalidateQueries({
queryKey: ['users', id],
})
Ошибка в одном символе приводит к тому, что инвалидация не срабатывает.
Другой пример:
['posts', 5]
И:
['posts', '5']
Для TanStack Query это разные ключи.
TypeScript умеет сохранять точные значения строковых литералов:
const key = ['users']
Тип:
string[]
Но:
const key = ['users'] as const
Тип:
readonly ['users']
Во втором случае TypeScript понимает точное содержимое массива.
as constas constconst key = ['posts', 5]
TypeScript выводит:
(string | number)[]
Точная структура потеряна.
as constconst key = ['posts', 5] as const
Тип:
readonly ['posts', 5]
Теперь:
type UserKey = ['users', number]
Использование:
const key: UserKey = ['users', 5]
Ошибка:
const key: UserKey = ['user', 5]
TypeScript:
Type '"user"' is not assignable to type '"users"'
TanStack Query активно использует readonly-массивы.
Поэтому лучше:
type UserKey = readonly ['users', number]
Или:
type UserKey = Readonly<['users', number]>
Ключи часто имеют иерархическую структуру:
['users', userId, 'posts']
Тип:
type UserPostsKey = readonly [
'users',
number,
'posts'
]
Теперь невозможно случайно поменять порядок:
['posts', 5, 'users']
Очень распространённый формат:
['posts', { page: 1, limit: 20 }]
Тип:
type PostsFilters = {
page: number
limit: number
}
type PostsKey = readonly [
'posts',
PostsFilters
]
Следующий код опасен:
const filters = {
page: 1,
limit: 20,
}
Позже:
filters.page = 2
Ключ изменился.
Для query keys желательно использовать неизменяемые объекты:
const filters = {
page: 1,
limit: 20,
} as const
На больших проектах ключи почти всегда выносятся в отдельные фабрики.
Пример:
export const userKeys = {
all: ['users'] as const,
detail: (id: number) =>
['users', id] as const,
posts: (id: number) =>
['users', id, 'posts'] as const,
}
Использование:
useQuery({
queryKey: userKeys.detail(5),
})
Инвалидация:
queryClient.invalidateQueries({
queryKey: userKeys.all,
})
Все ключи находятся в одном месте.
IDE показывает доступные варианты:
userKeys.detail
userKeys.posts
Нельзя случайно написать:
['usres']
Ключи используются:
TypeScript умеет автоматически выводить tuple-типы:
const userKeys = {
detail: (id: number) =>
['users', id] as const,
}
Тип результата:
readonly ['users', number]
Иногда требуется получить тип автоматически:
type UserDetailKey =
ReturnType<typeof userKeys.detail>
Результат:
readonly ['users', number]
Можно создавать generic-фабрики.
Пример:
const entityKeys = <T extends string>(entity: T) => ({
all: [entity] as const,
detail: (id: number) =>
[entity, id] as const,
})
Использование:
const postKeys = entityKeys('posts')
const userKeys = entityKeys('users')
В queryFn можно получить typed query key:
useQuery({
queryKey: ['users', 5] as const,
queryFn: ({ queryKey }) => {
const [, id] = queryKey
return fetchUser(id)
},
})
Однако TypeScript иногда теряет точность типов.
import type {
QueryFunctionContext
} fr om '@tanstack/react-query'
type UserKey = readonly ['users', number]
const fetchUser = async (
context: QueryFunctionContext<UserKey>
) => {
const [, id] = context.queryKey
return api.users.get(id)
}
Теперь id строго типизирован как
number.
При использовании queryOptions типизация становится
особенно полезной.
import {
queryOptions
} from '@tanstack/react-query'
const userQuery = (id: number) =>
queryOptions({
queryKey: ['users', id] as const,
queryFn: () => fetchUser(id),
})
useQuery(userQuery(5))
Prefetch:
queryClient.prefetchQuery(userQuery(5))
Invalidate:
queryClient.invalidateQueries({
queryKey: userQuery(5).queryKey,
})
В крупных приложениях полезно ограничить допустимые ключи глобально.
TanStack Query позволяет переопределить глобальный тип
QueryKey.
Пример:
type AppQueryKey =
| readonly ['users']
| readonly ['users', number]
| readonly ['posts']
| readonly ['posts', number]
Регистрация:
declare module '@tanstack/react-query' {
interface Register {
queryKey: AppQueryKey
}
}
Теперь ошибка появляется сразу:
['unknown']
TypeScript сообщит, что ключ не входит в разрешённый набор.
Глобальная типизация позволяет:
На больших проектах часто используют namespace-структуру:
['users']
['users', 'detail', id]
['users', 'posts', id]
Тип:
type UserKeys =
| readonly ['users']
| readonly ['users', 'detail', number]
| readonly ['users', 'posts', number]
queryClient.invalidateQueries({
queryKey: ['users'],
})
Инвалидирует все дочерние ключи.
['users', 'detail', 5]
Понятнее, чем:
['users', 5]
Слишком длинные ключи усложняют поддержку:
[
'users',
'detail',
'posts',
'comments',
'likes',
userId
]
Необходимо сохранять баланс между детализацией и простотой.
Для useInfiniteQuery ключи также должны быть
типизированы.
type FeedKey = readonly [
'feed',
{
lim it: number
}
]
Распространённый вариант:
type ProductsFilters = {
category?: string
page?: number
sort?: 'asc' | 'desc'
}
Ключ:
type ProductsKey = readonly [
'products',
ProductsFilters
]
Плохой пример:
queryKey: [
'products',
{
page,
timestamp: Date.now(),
},
]
Ключ меняется на каждом рендере.
Функции нельзя использовать в query keys:
['users', () => 5]
Ключи должны быть сериализуемыми.
Часто query keys связаны с router params.
Пример:
type RouteParams = {
userId: string
}
Преобразование:
const key = [
'users',
Number(params.userId),
] as const
type User = {
id: number
name: string
}
Фабрика:
const userKeys = {
detail: (user: User) =>
['users', user.id] as const,
}
Иногда полезно хранить сегменты отдельно:
const USERS_KEY = 'users'
const POSTS_KEY = 'posts'
Использование:
[USERS_KEY, id]
Однако чрезмерное дробление ухудшает читаемость.
На больших проектах фабрики превращаются в полноценный слой архитектуры.
Пример структуры:
src/
shared/
api/
queryKeys/
users.ts
posts.ts
comments.ts
Фабрики удобно хранить рядом с feature-модулями:
features/
users/
api/
queryKeys.ts
queryClient.invalidateQueries({
queryKey: userKeys.detail(5),
})
Без фабрики легко допустить несовпадение.
queryClient.setQueryData(
userKeys.detail(5),
user
)
Ключ используется повторно без дублирования.
const user = queryClient.getQueryData<User>(
userKeys.detail(5)
)
Современный TypeScript позволяет использовать
satisfies.
Пример:
const key = ['users', 5] as const
satisfies QueryKey
as const и satisfiesas constФиксирует литеральные значения.
satisfiesПроверяет совместимость типов без изменения итогового типа.
as const и satisfiesconst key =
['users', 5] as const
satisfies QueryKey
Это один из наиболее безопасных вариантов.
type Locale = 'ru' | 'en'
type ArticleKey = readonly [
'article',
Locale,
string
]
type TenantKey = readonly [
'tenant',
string,
'users',
number
]
type ApiVersion = 'v1' | 'v2'
type UsersKey = readonly [
ApiVersion,
'users',
number
]
Иногда разработчики создают чрезмерно сложные union-типы:
type AppQueryKey =
| readonly ['users']
| readonly ['users', number]
| readonly ['users', number, 'posts']
| readonly ['users', number, 'posts', number]
| readonly ['users', number, 'posts', number, 'comments']
Подобные конструкции быстро становятся трудно поддерживаемыми.
Наиболее распространённый подход:
as const;Хороший production-подход:
export const postKeys = {
all: ['posts'] as const,
lists: () =>
['posts', 'list'] as const,
list: (filters: PostsFilters) =>
['posts', 'list', filters] as const,
details: () =>
['posts', 'detail'] as const,
detail: (id: number) =>
['posts', 'detail', id] as const,
}
Преимущества: