TanStack Query активно использует возможности TypeScript-типизации
через дженерики. Именно благодаря им useQuery и
useMutation умеют автоматически выводить типы данных,
ошибок, аргументов мутаций и возвращаемых результатов.
Без корректной типизации работа с асинхронными запросами быстро
превращается в использование any, потерю автодополнения и
отсутствие контроля над структурой данных.
Дженерики позволяют:
Сигнатура useQuery в упрощённом виде выглядит следующим
образом:
useQuery<
TQueryFnData,
TError,
TData,
TQueryKey
>()
Каждый дженерик отвечает за отдельную часть поведения хука.
Первый дженерик определяет тип данных, которые возвращает
queryFn.
Пример:
type User = {
id: number
name: string
email: string
}
const fetchUser = async (): Promise<User> => {
const response = await fetch('/api/user')
if (!response.ok) {
throw new Error('Ошибка запроса')
}
return response.json()
}
const query = useQuery<User>({
queryKey: ['user'],
queryFn: fetchUser,
})
Теперь:
query.data
имеет тип:
User | undefined
До завершения запроса данные отсутствуют.
Поэтому TanStack Query всегда добавляет:
undefined
если не используется:
initialDataplaceholderDatasuspenseЭто фундаментальная часть типизации библиотеки.
Во многих случаях дженерик можно не указывать вручную.
TypeScript способен вывести тип автоматически:
const fetchUser = async (): Promise<User> => {
return {
id: 1,
name: 'Alex',
email: 'alex@test.com',
}
}
const query = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
})
Тип query.data будет автоматически определён как:
User | undefined
TypeScript не всегда способен корректно вывести тип.
Типизация ухудшается в случаях:
axios;Пример плохой типизации:
const fetchUser = async () => {
const response = await fetch('/api/user')
return response.json()
}
response.json() возвращает:
Promise<any>
В результате:
query.data // any
Наиболее безопасный подход — типизировать саму функцию:
const fetchUser = async (): Promise<User> => {
const response = await fetch('/api/user')
return response.json()
}
Тогда useQuery сможет вывести тип самостоятельно.
Второй дженерик отвечает за тип ошибки.
По умолчанию:
Error
Пример:
const query = useQuery<User, Error>({
queryKey: ['user'],
queryFn: fetchUser,
})
Теперь:
query.error
имеет тип:
Error | null
Во многих проектах ошибки имеют собственную структуру.
Например:
type ApiError = {
message: string
code: string
status: number
}
Тогда:
const query = useQuery<User, ApiError>({
queryKey: ['user'],
queryFn: fetchUser,
})
Теперь доступны:
query.error?.status
query.error?.code
При использовании Axios обычно применяется:
AxiosError
Пример:
import { AxiosError } from 'axios'
const query = useQuery<User, AxiosError>({
queryKey: ['user'],
queryFn: fetchUser,
})
Третий дженерик используется при трансформации данных через
select.
TQueryFnData:
TData:
select.type User = {
id: number
name: string
email: string
}
const query = useQuery<User, Error, string>({
queryKey: ['user'],
queryFn: fetchUser,
select: (user) => user.name,
})
Теперь:
query.data
имеет тип:
string | undefined
Хотя queryFn возвращает User.
type UserPreview = {
id: number
displayName: string
}
const query = useQuery<User, Error, UserPreview>({
queryKey: ['user'],
queryFn: fetchUser,
select: (user) => ({
id: user.id,
displayName: `${user.name} <${user.email}>`,
}),
})
Последний дженерик отвечает за тип queryKey.
Используется редко, но важен в сложных архитектурах.
type UserQueryKey = ['user', number]
const query = useQuery<
User,
Error,
User,
UserQueryKey
>({
queryKey: ['user', 1],
queryFn: ({ queryKey }) => {
const [, id] = queryKey
return fetchUser(id)
},
})
Теперь queryKey строго типизирован.
const query = useQuery<
User,
ApiError,
UserPreview,
['user', number]
>({
queryKey: ['user', 1],
queryFn: ({ queryKey }) => {
const [, id] = queryKey
return fetchUser(id)
},
select: (user) => ({
id: user.id,
displayName: user.name,
}),
})
useInfiniteQuery использует аналогичную схему:
useInfiniteQuery<
TQueryFnData,
TError,
TData,
TQueryKey,
TPageParam
>()
Добавляется:
TPageParam
type Post = {
id: number
title: string
}
const query = useInfiniteQuery<
Post[],
Error,
InfiniteData<Post[]>,
['posts'],
number
>({
queryKey: ['posts'],
initialPageParam: 1,
queryFn: ({ pageParam }) => {
return fetchPosts(pageParam)
},
getNextPageParam: (lastPage, pages) => {
return pages.length + 1
},
})
Сигнатура useMutation:
useMutation<
TData,
TError,
TVariables,
TContext
>()
TData — результат успешной мутации.
type User = {
id: number
name: string
}
const createUser = async (): Promise<User> => {
return {
id: 1,
name: 'Alex',
}
}
const mutation = useMutation<User>({
mutationFn: createUser,
})
Теперь:
mutation.data
имеет тип:
User | undefined
Один из важнейших дженериков.
Определяет тип аргумента мутации.
type CreateUserDto = {
name: string
email: string
}
const createUser = async (
data: CreateUserDto
): Promise<User> => {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(data),
})
return response.json()
}
const mutation = useMutation<
User,
Error,
CreateUserDto
>({
mutationFn: createUser,
})
Теперь:
mutation.mutate({
name: 'Alex',
email: 'alex@test.com',
})
типизирован полностью.
TypeScript запретит:
mutation.mutate({
wrongField: true,
})
Используется для optimistic updates.
Позволяет передавать контекст между:
onMutateonErroronSettledtype Context = {
previousUsers: User[]
}
const mutation = useMutation<
User,
Error,
CreateUserDto,
Context
>({
mutationFn: createUser,
onMutate: async (newUser) => {
const previousUsers = queryClient.getQueryData<User[]>([
'users',
]) || []
return {
previousUsers,
}
},
onError: (error, variables, context) => {
console.log(context?.previousUsers)
},
})
const mutation = useMutation<
User,
ApiError,
CreateUserDto,
Context
>({
mutationFn: createUser,
})
TypeScript умеет автоматически определять:
TDataTVariablesесли mutationFn типизирован правильно.
const createUser = async (
data: CreateUserDto
): Promise<User> => {
return {
id: 1,
name: data.name,
}
}
const mutation = useMutation({
mutationFn: createUser,
})
TypeScript автоматически выводит:
mutation.data // User | undefined
и:
mutation.mutate(data)
ожидает:
CreateUserDto
Самая распространённая ошибка:
const mutation = useMutation({
mutationFn: async (data) => {
return api.post('/users', data)
},
})
Тип data становится:
any
Вместе с ним разрушается вся типизация.
const mutation = useMutation({
mutationFn: async (
data: CreateUserDto
): Promise<User> => {
return api.post('/users', data)
},
})
В крупных проектах часто выносят конфигурацию запросов.
const userQueryOptions = queryOptions({
queryKey: ['user'],
queryFn: fetchUser,
})
Типы сохраняются автоматически:
const query = useQuery(userQueryOptions)
const createUserMutationOptions = mutationOptions({
mutationFn: createUser,
})
Одна из самых сложных тем — generic HTTP clients.
async function request<T>(
url: string
): Promise<T> {
const response = await fetch(url)
return response.json()
}
Использование:
const fetchUser = () => {
return request<User>('/api/user')
}
TanStack Query отлично подходит для создания generic hooks.
function useEntity<T>(
key: QueryKey,
fn: () => Promise<T>
) {
return useQuery<T>({
queryKey: key,
queryFn: fn,
})
}
const userQuery = useEntity<User>(
['user'],
fetchUser
)
function useGetById<T>(
entity: string,
id: number
) {
return useQuery<T>({
queryKey: [entity, id],
queryFn: async () => {
const response = await fetch(
`/api/${entity}/${id}`
)
return response.json()
},
})
}
const userQuery = useGetById<User>(
'users',
1
)
type Entity = {
id: number
}
function useEntity<T extends Entity>(
entity: string,
id: number
) {
return useQuery<T>({
queryKey: [entity, id],
queryFn: async () => {
const response = await fetch(
`/api/${entity}/${id}`
)
return response.json()
},
})
}
Теперь гарантируется наличие:
data.id
function useApiQuery<T = unknown>(
key: QueryKey,
fn: () => Promise<T>
) {
return useQuery<T>({
queryKey: key,
queryFn: fn,
})
}
Типизированные query keys особенно важны для:
queryClient.invalidateQueries()
export const userKeys = {
all: ['users'] as const,
detail: (id: number) =>
['users', id] as const,
}
Использование:
useQuery({
queryKey: userKeys.detail(1),
})
Без as const:
['users', 1]
превращается в:
(string | number)[]
С as const:
readonly ['users', 1]
Это критически важно для строгой типизации query keys.
TanStack Query использует readonly-массивы.
Поэтому правильный тип:
readonly ['users', number]
а не:
['users', number]
Частая проблема:
useQuery<User, Error, User, QueryKey>()
Подобная запись:
Наиболее надёжная стратегия:
Обычно они необходимы:
select;enabled не влияет на тип данных.
Даже при:
enabled: false
тип остаётся:
User | undefined
При использовании suspense:
useSuspenseQuery()
data больше не содержит undefined.
const query = useSuspenseQuery({
queryKey: ['user'],
queryFn: fetchUser,
})
Теперь:
query.data
имеет тип:
User
placeholderData должен соответствовать типу данных.
useQuery<User>({
queryKey: ['user'],
queryFn: fetchUser,
placeholderData: {
id: 0,
name: '',
email: '',
},
})
initialData также влияет на inference.
useQuery({
queryKey: ['counter'],
queryFn: async (): Promise<number> => {
return 5
},
initialData: 0,
})
TypeScript корректно определит:
number
select часто используется для derive-данных.
const query = useQuery<
User[],
Error,
string[]
>({
queryKey: ['users'],
queryFn: fetchUsers,
select: (users) =>
users.map((user) => user.name),
})
useQueries способен выводить tuple-типы.
const results = useQueries({
queries: [
{
queryKey: ['user'],
queryFn: fetchUser,
},
{
queryKey: ['posts'],
queryFn: fetchPosts,
},
],
})
TypeScript определит:
[
UseQueryResult<User>,
UseQueryResult<Post[]>
]
import { QueryFunctionContext } fr om '@tanstack/react-query'
type UserKey = readonly ['user', number]
const fetchUser = async (
context: QueryFunctionContext<UserKey>
) => {
const [, id] = context.queryKey
return request<User>(`/users/${id}`)
}
Не каждая мутация возвращает данные.
const deleteUser = async (
id: number
): Promise<void> => {
await fetch(`/users/${id}`, {
method: 'DELETE',
})
}
const mutation = useMutation<
void,
Error,
number
>({
mutationFn: deleteUser,
})
queryClient.setQueryData<User[]>(
['users'],
(old) => {
return old?.map((user) =>
user.id === updated.id
? updated
: user
)
}
)
В строгом режиме TypeScript особенно важны:
Без этого inference начинает деградировать по всей цепочке типов.