Dev Highlights

Доки по разработке

This project is maintained by teniryte

4. Query Functions и работа с API

queryFn отвечает только за получение данных. Всё, что связано с идентичностью кеша, живёт в queryKey, а всё, что связано с отображением, лучше выносить в select или UI.

Сигнатура

queryFn получает QueryFunctionContext. На практике важнее всего четыре поля:

Базовый пример

import type { QueryFunctionContext } from '@tanstack/react-query'

async function fetchTodoList({
  queryKey,
  signal,
}: QueryFunctionContext<readonly ['todos', { status: string }]>) {
  const [, filters] = queryKey
  const response = await fetch(`/api/todos?status=${filters.status}`, { signal })

  if (!response.ok) {
    throw new Error('Failed to load todos')
  }

  return response.json() as Promise<Todo[]>
}

Всегда пробрасывайте signal

Официальная рекомендация: если ваш клиент поддерживает AbortSignal, используйте его.

const query = useQuery({
  queryKey: ['todos'],
  queryFn: ({ signal }) =>
    fetch('/api/todos', { signal }).then((res) => res.json()),
})

Для axios >= 0.22:

import axios from 'axios'

useQuery({
  queryKey: ['todos'],
  queryFn: ({ signal }) => axios.get('/api/todos', { signal }),
})

Как работает cancellation

По умолчанию, если компонент размонтировался до завершения запроса, промис не обязательно будет отменён. Но если вы реально используете signal, то:

Это особенно важно при:

Не прячьте ошибки

Правильный подход:

async function fetchUser(id: string) {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error('Failed to load user')
  return res.json() as Promise<User>
}

Неправильный подход:

TanStack Query ожидает, что неуспешный запрос бросает исключение.

Где делать валидацию

Если API ненадёжное, валидируйте ответ прямо в queryFn.

const todosSchema = z.array(
  z.object({
    id: z.string(),
    title: z.string(),
    completed: z.boolean(),
  }),
)

async function fetchTodos({ signal }: QueryFunctionContext) {
  const res = await fetch('/api/todos', { signal })
  if (!res.ok) throw new Error('Failed to load todos')

  const json = await res.json()
  return todosSchema.parse(json)
}

Это лучше, чем пропускать повреждённые данные дальше в UI.

meta

meta удобно использовать для служебного контекста:

const query = useQuery({
  queryKey: ['reports', reportId],
  queryFn: ({ meta, signal }) =>
    fetchReport(reportId, { signal, source: meta?.source as string }),
  meta: { source: 'dashboard' },
})

Подходит для:

Не смешивайте fetch logic и UI logic

Плохая идея:

Хорошая идея:

const query = useQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  select: (projects) => projects.filter((project) => project.active),
})

Infinite Query и pageParam

import { infiniteQueryOptions } from '@tanstack/react-query'

export function messagesInfiniteOptions(chatId: string) {
  return infiniteQueryOptions({
    queryKey: ['messages', chatId],
    initialPageParam: null as string | null,
    queryFn: ({ pageParam, signal }) =>
      fetchMessages({ chatId, cursor: pageParam, signal }),
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  })
}

pageParam существует только внутри infinite query. Не пытайтесь моделировать его вручную в обычном useQuery.

Один API-слой для клиента и сервера

Лучший паттерн:

export function projectOptions(id: string) {
  return queryOptions({
    queryKey: ['projects', 'detail', id],
    queryFn: ({ signal }) => api.projects.get(id, { signal }),
  })
}

Тогда один и тот же набор опций можно использовать:

Частые ошибки