Дженерики в useQuery и useMutation

TanStack Query активно использует возможности TypeScript-типизации через дженерики. Именно благодаря им useQuery и useMutation умеют автоматически выводить типы данных, ошибок, аргументов мутаций и возвращаемых результатов.

Без корректной типизации работа с асинхронными запросами быстро превращается в использование any, потерю автодополнения и отсутствие контроля над структурой данных.

Дженерики позволяют:

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

Дженерики в useQuery

Сигнатура useQuery в упрощённом виде выглядит следующим образом:

useQuery<
  TQueryFnData,
  TError,
  TData,
  TQueryKey
>()

Каждый дженерик отвечает за отдельную часть поведения хука.


TQueryFnData

Первый дженерик определяет тип данных, которые возвращает 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

Почему data содержит undefined

До завершения запроса данные отсутствуют.

Поэтому TanStack Query всегда добавляет:

undefined

если не используется:

  • initialData
  • placeholderData
  • suspense

Это фундаментальная часть типизации библиотеки.


Автоматический вывод типов

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

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;
  • сложных transform-функций;
  • generic API clients;
  • динамических query functions;
  • неправильного return type;
  • отсутствия Promise-типов.

Пример плохой типизации:

const fetchUser = async () => {
  const response = await fetch('/api/user')

  return response.json()
}

response.json() возвращает:

Promise<any>

В результате:

query.data // any

Правильная типизация queryFn

Наиболее безопасный подход — типизировать саму функцию:

const fetchUser = async (): Promise<User> => {
  const response = await fetch('/api/user')

  return response.json()
}

Тогда useQuery сможет вывести тип самостоятельно.


TError

Второй дженерик отвечает за тип ошибки.

По умолчанию:

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

При использовании Axios обычно применяется:

AxiosError

Пример:

import { AxiosError } from 'axios'

const query = useQuery<User, AxiosError>({
  queryKey: ['user'],
  queryFn: fetchUser,
})

TData

Третий дженерик используется при трансформации данных через select.


Разница между TQueryFnData и TData

TQueryFnData:

  • оригинальный ответ queryFn.

TData:

  • итоговый тип после select.

Пример 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}>`,
  }),
})

TQueryKey

Последний дженерик отвечает за тип queryKey.

Используется редко, но важен в сложных архитектурах.


Типизация queryKey

type UserQueryKey = ['user', number]

const query = useQuery<
  User,
  Error,
  User,
  UserQueryKey
>({
  queryKey: ['user', 1],

  queryFn: ({ queryKey }) => {
    const [, id] = queryKey

    return fetchUser(id)
  },
})

Теперь queryKey строго типизирован.


Полная сигнатура useQuery

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 использует аналогичную схему:

useInfiniteQuery<
  TQueryFnData,
  TError,
  TData,
  TQueryKey,
  TPageParam
>()

Добавляется:

TPageParam

Типизация pageParam

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:

useMutation<
  TData,
  TError,
  TVariables,
  TContext
>()

TData в useMutation

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

TVariables

Один из важнейших дженериков.

Определяет тип аргумента мутации.


Пример типизации variables

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,
})

TContext

Используется для optimistic updates.

Позволяет передавать контекст между:

  • onMutate
  • onError
  • onSettled

Пример optimistic update

type 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)
  },
})

Полная сигнатура useMutation

const mutation = useMutation<
  User,
  ApiError,
  CreateUserDto,
  Context
>({
  mutationFn: createUser,
})

Автоматический вывод типов в useMutation

TypeScript умеет автоматически определять:

  • TData
  • TVariables

если 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

Проблемы any в useMutation

Самая распространённая ошибка:

const mutation = useMutation({
  mutationFn: async (data) => {
    return api.post('/users', data)
  },
})

Тип data становится:

any

Вместе с ним разрушается вся типизация.


Правильная типизация mutationFn

const mutation = useMutation({
  mutationFn: async (
    data: CreateUserDto
  ): Promise<User> => {
    return api.post('/users', data)
  },
})

Типизация queryOptions

В крупных проектах часто выносят конфигурацию запросов.


Пример

const userQueryOptions = queryOptions({
  queryKey: ['user'],
  queryFn: fetchUser,
})

Типы сохраняются автоматически:

const query = useQuery(userQueryOptions)

Типизация mutationOptions

const createUserMutationOptions = mutationOptions({
  mutationFn: createUser,
})

Generic API clients

Одна из самых сложных тем — 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')
}

Generic hooks

TanStack Query отлично подходит для создания generic hooks.


Пример useEntity

function useEntity<T>(
  key: QueryKey,
  fn: () => Promise<T>
) {
  return useQuery<T>({
    queryKey: key,
    queryFn: fn,
  })
}

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

const userQuery = useEntity<User>(
  ['user'],
  fetchUser
)

Generic CRUD hooks


useGetById

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
)

Ограничение generic типов


extends

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

Default generic parameters


Пример

function useApiQuery<T = unknown>(
  key: QueryKey,
  fn: () => Promise<T>
) {
  return useQuery<T>({
    queryKey: key,
    queryFn: fn,
  })
}

Типизация invalidateQueries

Типизированные 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 и литеральные типы

Без as const:

['users', 1]

превращается в:

(string | number)[]

С as const:

readonly ['users', 1]

Это критически важно для строгой типизации query keys.


readonly query keys

TanStack Query использует readonly-массивы.

Поэтому правильный тип:

readonly ['users', number]

а не:

['users', number]

Ошибки чрезмерной ручной типизации

Частая проблема:

useQuery<User, Error, User, QueryKey>()

Подобная запись:

  • ухудшает читаемость;
  • усложняет поддержку;
  • ломает inference;
  • создаёт лишний шум.

Предпочтительный подход

Наиболее надёжная стратегия:

  • типизировать API-функции;
  • позволять TypeScript выводить типы автоматически;
  • использовать явные дженерики только при необходимости.

Когда нужны явные дженерики

Обычно они необходимы:

  • при select;
  • при custom error types;
  • при generic hooks;
  • при сложных query keys;
  • при optimistic updates;
  • при reusable abstractions.

Типизация enabled

enabled не влияет на тип данных.

Даже при:

enabled: false

тип остаётся:

User | undefined

Suspense и типизация

При использовании suspense:

useSuspenseQuery()

data больше не содержит undefined.


Пример

const query = useSuspenseQuery({
  queryKey: ['user'],
  queryFn: fetchUser,
})

Теперь:

query.data

имеет тип:

User

Типизация placeholderData

placeholderData должен соответствовать типу данных.


Пример

useQuery<User>({
  queryKey: ['user'],
  queryFn: fetchUser,

  placeholderData: {
    id: 0,
    name: '',
    email: '',
  },
})

Типизация initialData

initialData также влияет на inference.


Пример

useQuery({
  queryKey: ['counter'],

  queryFn: async (): Promise<number> => {
    return 5
  },

  initialData: 0,
})

TypeScript корректно определит:

number

Типизация sel ect с memoization

select часто используется для derive-данных.


Пример

const query = useQuery<
  User[],
  Error,
  string[]
>({
  queryKey: ['users'],
  queryFn: fetchUsers,

  select: (users) =>
    users.map((user) => user.name),
})

Типизация useQueries

useQueries способен выводить tuple-типы.


Пример

const results = useQueries({
  queries: [
    {
      queryKey: ['user'],
      queryFn: fetchUser,
    },

    {
      queryKey: ['posts'],
      queryFn: fetchPosts,
    },
  ],
})

TypeScript определит:

[
  UseQueryResult<User>,
  UseQueryResult<Post[]>
]

Типизация QueryFunctionContext


Пример

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}`)
}

useMutation и Promise

Не каждая мутация возвращает данные.


Пример delete-запроса

const deleteUser = async (
  id: number
): Promise<void> => {
  await fetch(`/users/${id}`, {
    method: 'DELETE',
  })
}

Типизация

const mutation = useMutation<
  void,
  Error,
  number
>({
  mutationFn: deleteUser,
})

Типизация optimistic updates


setQueryData

queryClient.setQueryData<User[]>(
  ['users'],
  (old) => {
    return old?.map((user) =>
      user.id === updated.id
        ? updated
        : user
    )
  }
)

Strict mode и TanStack Query

В строгом режиме TypeScript особенно важны:

  • корректные Promise-типы;
  • отсутствие any;
  • правильные generic constraints;
  • readonly query keys;
  • безопасные null-checks.

Без этого inference начинает деградировать по всей цепочке типов.