Dev Highlights

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

This project is maintained by teniryte

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

queryFn — асинхронная функция, которая фактически получает данные. В v5 она всегда вызывается с объектом контекста:

type QueryFunctionContext<TQueryKey = QueryKey, TPageParam = never> = {
  queryKey: TQueryKey;
  signal: AbortSignal;
  meta: Record<string, unknown> | undefined;
  pageParam?: TPageParam;
};

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

async function fetchTodos({ queryKey, signal }: QueryFunctionContext) {
  const [_key, { userId }] = queryKey;
  const res = await fetch(`/api/users/${userId}/todos`, { signal });
  if (!res.ok) throw new Error('Не удалось загрузить задачи');
  return res.json() as Promise<Todo[]>;
}

useQuery({
  queryKey: ['todos', { userId }],
  queryFn: fetchTodos,
});

Лайфхаки

Обработка отмены (AbortSignal)

TanStack Query отменяет активный запрос, если:

Поэтому важно прокидывать signal во все fetch/axios. Например, для Axios:

import axios from 'axios';

const fetchUser = async ({ signal, queryKey }) => {
  const [, id] = queryKey;
  const { data } = await axios.get(`/api/users/${id}`, { signal });
  return data;
};

Если вы вручную бросаете исключение при отмене, используйте throw new DOMException('Aborted', 'AbortError'). Это позволит Query не считать отмену ошибкой и не запускать retry.

В v5 также можно установить networkMode: 'offlineFirst' и получать signal даже когда запрос «медленно» прогружается: Query отменит запрос при переходе оффлайн/онлайн автоматически.

Доступ к meta

meta удобно использовать для трассировки или выбора клиента.

const githubQuery = (username: string) => ({
  queryKey: ['github', username],
  queryFn: ({ meta }) => meta!.client.get(`/users/${username}`),
  meta: { client: githubClient },
});

Дополнительно через meta можно прокинуть:

Паттерн «контекста запроса»

export const queries = {
  article: (slug: string) => ({
    queryKey: ['article', slug],
    queryFn: ({ signal }) => api.articles.get(slug, { signal }),
    staleTime: 5 * 60_000,
  }),
};

Такой объект удобно использовать и на сервере для prefetchQuery.

Совет: держите все фабрики в одном файле (queries.ts). Тогда prefetchQuery(queries.article(slug)) в getServerSideProps или React Server Components будет полностью типобезопасен, и вы не забудете включить те же staleTime/gcTime.

Обработка ошибок

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

async function fetchTodos(ctx: QueryFunctionContext) {
  const data = await client.getTodos(ctx);
  return todosSchema.parse(data);
}

Лайфхаки

Повторное использование queryFn

export const todoQueryOptions = (id: string) => ({
  queryKey: ['todo', id],
  queryFn: (ctx) => api.todo.get({ id, signal: ctx.signal }),
});

// Хук
export const useTodo = (id: string) => useQuery(todoQueryOptions(id));

Единая функция исключает ошибки при гидратации (dehydrate/Hydrate).

Можно добавить общий билдер:

const buildQueryFn =
  <TResult>(fetcher: (ctx: QueryFunctionContext) => Promise<TResult>) =>
  (overrides: Partial<QueryOptions<TResult>> = {}) => ({
    queryFn: fetcher,
    staleTime: 30_000,
    gcTime: 5 * 60_000,
    ...overrides,
  });

export const userQuery = (id: string) =>
  buildQueryFn(({ signal }) => api.user.get(id, { signal }))({
    queryKey: ['user', id],
  });

Так вы стандартизируете staleTime/gcTime/retry и минимизируете опечатки.

Параметры запроса

Работа с pageParam и бесконечными списками

type PostsPage = { items: Post[]; nextCursor?: string };

export const postsInfiniteQuery = (cursor?: string) => ({
  queryKey: ['posts', { cursor }],
  queryFn: ({ pageParam = cursor, signal }) =>
    api.posts.list({ cursor: pageParam, signal }),
  initialPageParam: null as string | null,
  getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});

const { data, fetchNextPage, isFetchingNextPage } =
  useInfiniteQuery(postsInfiniteQuery());

Тонкости

Idempotency и кеш

queryFn должен быть чистым: один и тот же queryKey -> один и тот же результат. Побочные эффекты (запись) выполняйте в мутациях.

Проверочный чек-лист перед созданием queryFn:

Типизация

type TodosQueryKey = ReturnType<typeof todosQueryOptions>['queryKey'];

const fetcher: QueryFunction<Todo[], TodosQueryKey> = async ({ queryKey }) => {
  const [_key, { userId }] = queryKey;
  return client.getTodos(userId);
};

Дополнительно

Prefetch, SSR и RSC

// сервер
await queryClient.prefetchQuery(todoQueryOptions(id));
const dehydratedState = dehydrate(queryClient);

// клиент
<Hydrate state={dehydratedState}>
  <TodoPage />
</Hydrate>

Placeholder, Initial Data и синхронизация с API

Пример «теплого» кеша:

const useTodoList = (filters: Filters) =>
  useQuery({
    ...todoListQuery(filters),
    placeholderData: (prev) =>
      prev ? { ...prev, isPlaceholder: true } : undefined,
    select: (data) => data.items,
  });

Диагностика и отладка API

Тестирование

test('fetches profile once', async () => {
  const queryClient = new QueryClient();
  server.use(rest.get('/profile', ...));

  const result = await queryClient.fetchQuery(profileQueryOptions());
  expect(result).toMatchObject({ id: 'user_1' });
  expect(server.handledRequests('/profile')).toHaveLength(1);
});