Доки по разработке
This project is maintained by teniryte
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,
});
Лайфхаки
queryKey внутри queryFn, чтобы не терять типы.select, иначе кеш будет раздуваться.meta: { requestId: crypto.randomUUID() }, если хотите трекать запросы в логах.AbortSignal)TanStack Query отменяет активный запрос, если:
fetchType: 'replace'.Поэтому важно прокидывать 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 отменит запрос при переходе оффлайн/онлайн автоматически.
metameta удобно использовать для трассировки или выбора клиента.
const githubQuery = (username: string) => ({
queryKey: ['github', username],
queryFn: ({ meta }) => meta!.client.get(`/users/${username}`),
meta: { client: githubClient },
});
Дополнительно через meta можно прокинуть:
suspenseKey — для объединения нескольких запросов в useSuspenseQuery.cacheBuster или tenantId, если один и тот же queryFn работает в разных контекстах.meta.logger?.info(...)) без глобальных синглтонов.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.
throw new Error), а не возвращайте null.errorFormatter, если используете zod/superstruct.zod-схему до return.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);
}
Лайфхаки
retry(failureCount, error) прямо в query options, чтобы не ретраить 4xx.GraphQLError, а в onError уже решать, что показывать пользователю.Object.assign(error, { meta: ctx.meta }).queryFnexport 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 и минимизируете опечатки.
{ page, filter }), а не строки с конкатенацией.queryFn делайте деструктуризацию: const [{ page, filter }] = queryKey.slice(1);createKey('todos', { ... }).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());
Тонкости
pageParam живёт только внутри queryFn, поэтому обязательно передавайте его в API.hasMore=true, храните отдельное поле nextCursor, иначе Query перестанет запрашивать новые страницы.cursor в queryKey: так можно делать queryClient.invalidateQueries({ queryKey: ['posts'] }) без доп. параметров.queryFn должен быть чистым: один и тот же queryKey -> один и тот же результат. Побочные эффекты (запись) выполняйте в мутациях.
Проверочный чек-лист перед созданием queryFn:
queryFn.structuralSharing не сработает.select, чтобы срезать лишние поля перед выдачей в компонент.type TodosQueryKey = ReturnType<typeof todosQueryOptions>['queryKey'];
const fetcher: QueryFunction<Todo[], TodosQueryKey> = async ({ queryKey }) => {
const [_key, { userId }] = queryKey;
return client.getTodos(userId);
};
Дополнительно
queryFn через InferQueryOptions<typeof todoQueryOptions>.Awaited<ReturnType<typeof queryFn>>, чтобы избегать any при dehydrate.type TodoQueryData = Awaited<ReturnType<typeof fetcher>>;.// сервер
await queryClient.prefetchQuery(todoQueryOptions(id));
const dehydratedState = dehydrate(queryClient);
// клиент
<Hydrate state={dehydratedState}>
<TodoPage />
</Hydrate>
queryFn, что и на клиенте (см. «паттерн контекста»).await queryClient.ensureQueryData(...) прямо в серверном компоненте.headers/cookies через meta, чтобы один и тот же queryFn использовал правильный fetch.initialData задаёт готовый кеш и влияет на dataUpdatedAt.placeholderData отображается мгновенно, но не попадает в кеш. Идеально для списков: placeholderData: keepPreviousData.queryClient.setQueryData после мутации, чтобы не ждать полного refetch.Пример «теплого» кеша:
const useTodoList = (filters: Filters) =>
useQuery({
...todoListQuery(filters),
placeholderData: (prev) =>
prev ? { ...prev, isPlaceholder: true } : undefined,
select: (data) => data.items,
});
queryClient.getQueryCache().findAll({ queryKey: ['todos'] }) — быстрый способ увидеть все вариации ключей.showQueryKey, чтобы ловить опечатки в фильтрах.onSuccess для логирования, а не queryFn: так не исказится кеш.queryFn и вызывайте queryClient.fetchQuery.queryFn в отдельный модуль, а в тесте подключайте setupServer.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);
});
AbortController и queryClient.fetchQuery(..., { signal }).server.events в MSW).