Доки по разработке
This project is maintained by teniryte
TanStack Query управляет жизненным циклом сетевых запросов, кешированием и синхронизацией состояния. В v5 единый источник правды — Query Cache, а useQuery, useSuspenseQuery, useQueries и роутеры подписываются на него через наблюдателей. Любые компоненты, серверные лоадеры и фоновые задачи могут читать и прогревать один и тот же кеш.
useQueryimport { useQuery } from '@tanstack/react-query';
import { fetchTodos } from './api';
export function Todos() {
const {
data,
error,
status, // 'pending' | 'error' | 'success'
fetchStatus, // 'idle' | 'fetching' | 'paused'
refetch,
} = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 30_000,
});
if (status === 'pending') return <Spinner />;
if (status === 'error') return <ErrorView error={error} />;
return (
<>
<TodoList todos={data} />
<button onClick={() => refetch()}>Обновить</button>
</>
);
}
status описывает наличие данных, а fetchStatus — сетевую активность. Это позволяет показывать «фоновые» индикаторы без скрытия данных. queryFn автоматически получает AbortSignal, meta и сам queryKey, поэтому удобно писать универсальные функции:
async function fetchTodos({ queryKey, signal, meta }: QueryFunctionContext) {
const [_key, { userId }] = queryKey;
const res = await fetch(`/api/users/${userId}/todos`, { signal });
if (!res.ok) throw new Error('Failed');
const data = await res.json();
meta?.analytics?.track('todos.loaded', { count: data.length });
return data;
}
Все fetch/Axios запросы следует связать с signal, иначе отменённый запрос продолжит работу впустую.
queryKey должен однозначно описывать набор данных и быть сериализуемым. Используйте кортежи/объекты (['todos', { filters }]) вместо конкатенации строк.queryFn не принимает параметры, кроме контекста; берите всё из queryKey или замыкания. Так проще мемоизировать и переиспользовать один и тот же queryFn.refetch() возвращает промис, поэтому можно дождаться завершения и выполнять цепочки (например, закрыть модалку только после свежих данных).dataUpdatedAt и errorUpdatedAt — полезно для «последний раз обновлено …» и условного throwOnError.isStale, isFetching, isRefetching живут независимо. Например, данные могут быть уже устаревшими (isStale: true), но сетевой запрос ещё не запущен (fetchStatus: 'idle').const controller = new AbortController(); refetch({ signal: controller.signal });. Это удобно для Wizard-форм и «быстрых переключателей», где нужно мгновенно отменять предыдущие запросы.| Опция | Значение по умолчанию | Назначение |
|---|---|---|
queryKey |
— | Уникальный ключ (обычно массив), определяющий кеш |
queryFn |
— | Асинхронная функция, возвращающая данные |
enabled |
true |
Управляет тем, когда запрос активируется |
staleTime |
0 |
Время свежести данных; Infinity отключает фоновые рефетчи |
gcTime (cacheTime) |
5 * 60_000 |
Время жизни кеша без подписчиков |
refetchOnWindowFocus |
true |
Авто-рефетч при фокусе вкладки |
refetchOnMount |
true |
Управляет рефетчем при маунте (может быть 'always', false или функция) |
refetchOnReconnect |
true |
Ре-фетч при восстановлении сети |
refetchInterval |
false |
Периодический поллинг (мс или функция) |
refetchIntervalInBackground |
false |
Продолжает поллинг даже без фокуса окна |
retry |
3 |
Количество повторных попыток при ошибке (false, число или функция) |
retryDelay |
function |
Backoff между попытками; можно задать кастомную функцию |
networkMode |
'online' |
'online' | 'always' | 'offlineFirst' — влияет на поведение при офлайне |
suspense |
false |
Использовать ли Suspense для ожидания запроса |
throwOnError |
false |
Прокидывать ли ошибку наружу (в error boundary/Suspense) |
select |
— | Трансформация данных перед тем, как они попадут в компонент |
placeholderData |
— | Временные данные до первого успешного ответа |
initialData |
— | Статические или гидратированные данные, сразу считающиеся «успешными» |
initialDataUpdatedAt |
Date.now() |
Таймстемп для initialData, чтобы управлять свежестью |
keepPreviousData |
false |
Сохранять предыдущие данные пока запрашиваем новые |
structuralSharing |
true |
Включает умное сравнение данных для минимизации ререндеров |
notifyOnChangeProps |
'auto' |
Тонкая настройка, какие поля из состояния вызывают обновление |
refetchType |
'active' |
'active' | 'inactive' | 'all' — какие запросы рефетчить при invalidate |
meta |
{} |
Пользовательские метаданные, попадающие в queryFn, обработчики и события |
staleTime, gcTime и авто-рефетчиstaleTime отвечает за свежесть данных, но не отменяет фоновые обновления. Если refetchOnWindowFocus: true, то при возврате во вкладку запрос всё равно повторится, даже если данные свежие — задайте refetchOnWindowFocus: 'always' | false, чтобы переопределить.gcTime (раньше cacheTime) запускается после того, как все наблюдатели отписались. Если вы переключаетесь между вкладками с keepPreviousData: true, то запрос остаётся «active», поэтому gcTime не тикает.staleTime: Infinity + refetchOnMount: false превращает запрос в «один раз загрузил» — полезно для справочников и фич-флагов.refetchOnReconnect учитывает networkMode. В offlineFirst новые запросы ставятся в очередь, но «рефетч» начнёт выполняться только когда сеть восстановится.queryKey и фабрики опцийСоздавайте фабрики query-опций, чтобы переиспользовать одни и те же настройки в компонентах, роутерах и при префетче:
import { queryOptions } from '@tanstack/react-query';
export const todosQueryOptions = (userId: string, filters: TodosFilters) =>
queryOptions({
queryKey: ['users', userId, 'todos', { filters }],
queryFn: () => fetchTodos({ userId, filters }),
staleTime: filters.status === 'archive' ? Infinity : 30_000,
meta: { feature: 'todos' },
});
queryClient.ensureQueryData(todosQueryOptions(userId, filters)), в серверных лоадерах, в Next.js Route Handlers или TanStack Router.useMemo для больших фильтров).queryKeyHashFn, чтобы контролировать сериализацию и экономить память.todosInfiniteQueryOptions, todosSummaryQueryOptions. Переиспользуйте части ключа (baseKey = ['users', userId, 'todos']), чтобы массово инвалидировать «семейство» запросов.queryClient.setQueryDefaults(['todos'], todosQueryDefaults). Это особенно полезно, если у вас сотни компонент: фабрика может читать общие дефолты и переопределять точечно.const { data, error, isFetching } = useQuery({
queryKey: ['report', reportId],
queryFn: () => fetchReport(reportId),
retry: (failureCount, error) =>
error instanceof ValidationError ? false : failureCount < 5,
retryDelay: failureCount => Math.min(1000 * 2 ** failureCount, 15_000),
networkMode: 'offlineFirst', // собирает запросы в очередь, пока нет сети
refetchOnReconnect: false, // иначе offlineFirst сразу перезапустит
});
networkMode: 'always' полезен для Fire-and-forget действий (они продолжат выполняться даже без navigator.onLine).retry: false, чтобы моментально подсветить ошибку и позволить пользователю вручную рефетчить.meta удобно использовать для логирования: meta: { analyticsEvent: 'LoadReport' }.const queryClient = useQueryClient();
async function optimisticToggle(todoId: string) {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData<Todo[]>(['todos']);
queryClient.setQueryData(['todos'], old =>
old?.map(todo =>
todo.id === todoId ? { ...todo, completed: !todo.completed } : todo,
),
);
try {
await updateTodo(todoId);
} catch (error) {
queryClient.setQueryData(['todos'], previous);
throw error;
} finally {
queryClient.invalidateQueries({ queryKey: ['todos'], refetchType: 'active' });
}
}
queryClient.cancelQueries не просто отменяет фетч — он дождётся завершения текущих запросов и снимет «устаревание», что критично перед оптимистичными апдейтами.refetch({ cancelRefetch: false }) позволяет запускать параллельные фетчи (например, сравнить ответы разных дата-центров). Используйте осторожно, чтобы не «топить» API.AbortController в meta и отменять его в route leave.import { useSuspenseQuery } from '@tanstack/react-query';
function TodoListScreen() {
const query = useSuspenseQuery({
...todosQueryOptions('currentUser', defaultFilters),
suspense: true,
throwOnError: (error, query) => query.state.dataUpdatedAt === 0,
});
return <TodoList todos={query.data} />;
}
useSuspenseQuery автоматически включает suspense: true и throwOnError: true. Подключайте <ErrorBoundary> рядом, чтобы ловить исключения.query.state.data !== undefined).useSuspenseQuery на сервере (Next.js app router) не забудьте завернуть компонент в ErrorBoundary даже на серверной стороне, иначе ошибки упадут в лог и не попадут в UI.useQueryState и селективные подпискиuseQueryState берёт только нужные части состояния, не создавая observer в компоненте:
const name = useQueryState({
...userQueryOptions(id),
select: data => data.profile.name,
placeholderData: (prev) => prev ?? '—',
});
refetch, а только выбранное значение; оно обновится при любом изменении данных, даже если другой экран сделал invalidate.useQuery, хук не знает об isFetching; если нужен индикатор, заведите соседний useIsFetching({ queryKey }).notifyOnChangeProps: 'tracked'const query = useQuery({
...todosQueryOptions(userId, filters),
notifyOnChangeProps: 'tracked',
});
const items = query.data; // только доступ к data вызовет ререндер
const isFetching = query.isFetching;
'tracked' объект результата превращается в Proxy и отслеживает, какие поля читает компонент. Если компонент не использует error, его обновления перестанут триггерить ререндер.const { data, ...rest } = useQuery(...)) — так вы за один рендер коснётесь всех полей и потеряете выгоду. Сначала сохраните объект (const query = useQuery(...)), а потом обращайтесь к нужным свойствам.notifyOnChangeProps: ['data', 'isFetching'] — альтернатива Proxy, если список зависимостей фиксирован.enabledconst userId = useUserId(); // может быть undefined
const user = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId!),
enabled: Boolean(userId),
placeholderData: () => queryClient.getQueryData(['user', 'anonymous']),
});
enabled: false полностью отключает автоматические рефетчи (включая refetchOnWindowFocus). Чтобы запустить запрос вручную, используйте refetch() или queryClient.ensureQueryData. Комбинируйте enabled с select, чтобы зависимые запросы не падали:
const audit = useQuery({
queryKey: ['audit', project.data?.auditId],
enabled: Boolean(project.data?.auditId),
queryFn: () => fetchAudit(project.data!.auditId!),
staleTime: 5 * 60_000,
});
select, placeholderData и keepPreviousDataconst { data: todos, isFetching, isPlaceholderData } = useQuery({
queryKey: ['todos', filters],
queryFn: () => fetchTodos(filters),
select: data => data.items,
placeholderData: keepPreviousData, // встроенный helper
initialDataUpdatedAt: Date.now(), // управляем свежестью при гидрации
});
select выполняется после кеша, поэтому не ломает шэринг между компонентами. Между select и structuralSharing можно добиться почти бесплатных derived-данных.placeholderData: keepPreviousData удерживает предыдущие данные, пока новый запрос в полёте, что убирает мерцание при пагинации.initialData считает данные «успешными» и не помечает их как isPlaceholderData. Добавьте initialDataUpdatedAt, если хотите, чтобы они сразу считались свежими/устаревшими в зависимости от таймстемпа.placeholderData может быть функцией: placeholderData: (prev) => prev ?? skeleton. Так можно переиспользовать предыдущую версию другого ключа (queryClient.getQueryData(['todos', 'all'])), пока не загрузился новый фильтр.structuralSharing, если API всегда возвращает новый массив: structuralSharing: (oldData, newData) => deepmerge(oldData, newData). Это резко снижает количество изменений ссылок.useInfiniteQueryconst feed = useInfiniteQuery({
queryKey: ['feed', filters],
queryFn: ({ pageParam = 1 }) => fetchFeed({ page: pageParam, filters }),
getNextPageParam: (lastPage) => lastPage.next ?? undefined,
getPreviousPageParam: (firstPage) => firstPage.prev ?? undefined,
select: data => ({
pages: data.pages.map(page => page.items),
pageParams: data.pageParams,
}),
keepPreviousData: true,
});
keepPreviousData и контролируйте pageParam через состояние компонента.feed.fetchNextPage() + hasNextPage.staleTime и gcTime, чтобы страницы не выгружались между переходами.initialPageParam позволяет задать старт (например, сервер уже знает последний просмотренный чанк). Это избавляет от мерцаний «пустого» списка.pageParam может быть объектом (cursor). В таком случае храните pageParams в кеше и восстанавливайте их при гидрации, чтобы кнопка «загрузить ещё» знала, какой курсор был последним.getNextPageParam с meta, чтобы логировать, на каком курсоре застрял пользователь (например, meta: { type: 'feed' }).useQueriesconst results = useQueries({
queries: [
{ queryKey: ['user', id], queryFn: () => fetchUser(id) },
{ queryKey: ['posts', id], queryFn: () => fetchPosts(id), staleTime: 60_000 },
],
});
Каждый объект внутри queries принимает те же опции, что и useQuery. В v5 useQueries возвращает массив (или объект, если задать combine):
const profile = useQueries({
queries: [
userQueryOptions(id),
todosQueryOptions(id, { status: 'active' }),
],
combine: results => ({
isPending: results.some(r => r.isPending),
data: {
user: results[0].data,
todos: results[1].data ?? [],
},
}),
});
combine избавляет от ручной синхронизации состояний и создаёт единый объект, пригодный для Suspense.placeholderData индивидуально для каждого запроса и получать мгновенный UI.staleTime/enabled к каждому элементу. В противном случае TanStack Query будет считать что «объект изменился» и отписывать/подписывать observers при каждом рендере.combine с Object.fromEntries, чтобы вернуть именованный объект: так проще пробрасывать результат в child-компоненты.function UserLink({ id, children }) {
const queryClient = useQueryClient();
const preload = () =>
queryClient.prefetchQuery({
...userQueryOptions(id),
staleTime: 5 * 60_000,
});
return (
<Link
href={`/users/${id}`}
onPointerEnter={preload}
onFocus={preload}
>
{children}
</Link>
);
}
prefetchQuery не выбрасывает ошибок: оборачивайте в void queryClient.prefetchQuery(...), если вам неважен результат.Promise.all из фабрик queryOptions, а затем queryClient.setQueriesData, чтобы обеспечить атомарный прогрев.ensureQueryData + router.navigate позволяет гарантировать, что переход произойдёт только после того, как данные оказались в кеше (полезно для SPA wizard’ов).QueryClientconst queryClient = useQueryClient();
// Прогрев кеша
await queryClient.prefetchQuery(todosQueryOptions(userId, filters));
// Гарантированное получение данных (используется в роутерах)
const todos = await queryClient.ensureQueryData(todosQueryOptions(userId, filters));
// Оптимистичное обновление
queryClient.setQueryData(['todos'], old => [
...(old ?? []),
optimisticTodo,
]);
// Инвалидация (active|inactive|all)
queryClient.invalidateQueries({ queryKey: ['todos'], refetchType: 'active' });
// Ручное чтение состояния
const state = queryClient.getQueryState(['todos']);
queryClient.setQueryDefaults(['todos'], { staleTime: 60_000 }), чтобы не дублировать опции во всех компонентах.resetQueries полезен при logout: сбрасывает данные и отменяет в полёте запросы.queryClient.getQueryCache().find({ queryKey }) подходит для отладки: можно посмотреть state.data и observers.length без рендера.queryClient.getQueryCache().subscribe((event) => { ... }) даёт доступ к событиям added, removed, updated. Через неё удобно строить DevTools или собственную телеметрию.await queryClient.prefetchQuery(...), затем dehydrate(queryClient) и передавайте результат на клиент.hydrate(queryClient, dehydratedState) до маунта <QueryClientProvider>.defer или ensureQueryData, чтобы на экране уже были данные и компоненты лишь подписывались на кеш.initialData и указать staleTime: Infinity, добившись «build-time caching».dehydrate(queryClient, { shouldDehydrateQuery }): можно исключить из состояния тяжёлые или чувствительные данные (например, GraphQL introspection).app router совмещайте prefetchQuery с cache() (React Server Components), чтобы не гонять одни и те же запросы на сервере несколько раз.headers/cookies в fetcher внутри server actions, иначе клиентский кеш получит ответ не того пользователя.onSuccess, onError, onSettled доступны как на уровне конкретного запроса, так и в defaultOptions. В v5 они получают { data, error, query, meta }.queryClient.getQueryCache().subscribe(listener) или QueryClient-события — можно прокидывать в Sentry/Datadog.useEffect + query.isSuccess или подписывайтесь на queryClient.getQueryData внутри сервиса.useIsFetching и useIsMutating помогут строить глобальные индикаторы: const isGlobalLoading = useIsFetching() > 0;. Передавайте queryKey, чтобы индикатор работал только для конкретного семейства запросов.onSuccess для авто-закрытия: onSuccess: () => setOpen(false). Так вы не забудете выключить модалку в случае ошибок/отмен.import { focusManager, onlineManager } from '@tanstack/react-query';
focusManager.setEventListener((handleFocus) => {
function onVisibility() {
if (document.visibilityState === 'visible') handleFocus();
}
window.addEventListener('visibilitychange', onVisibility, false);
return () => window.removeEventListener('visibilitychange', onVisibility);
});
onlineManager.setEventListener((setOnline) => {
function onStatus(e: CustomNetworkEvent) {
setOnline(e.detail === 'online');
}
window.addEventListener('intranet-status', onStatus);
return () => window.removeEventListener('intranet-status', onStatus);
});
window и navigator.onLine. Так вы контролируете, когда QueryClient решает «мы снова онлайн».onlineManager.setOnline(false) — полезно для режима «Работа офлайн», когда вы временно запрещаете background refetch.@tanstack/query-persist-client-core: persistQueryClient({ persister: createSyncStoragePersister({ storage: localStorage }) }) сохранит кеш между перезапусками и восстановит незавершённые мутации.maxAge и buster. Изменение схемы данных? Увеличьте buster, чтобы клиент сбросил устаревший кеш.todosQueryOptions(userId) и переиспользуйте её одновременно в компонентах, роутерах и на сервере.refetchOnWindowFocus: false по умолчанию и включайте точечно.setQueryData, затем вызовите invalidateQueries, чтобы серверная версия синхронизировалась автоматически.loader = () => queryClient.ensureQueryData(query) в маршрутизаторе, а компоненту отдавайте просто useSuspenseQuery(query).refetchInterval только при открытой вкладке «Мониторинг», выключая его, когда пользователь уходит на другой экран.refetchInterval с refetchIntervalInBackground: false, чтобы запросы останавливались, когда пользователь переключается в другую вкладку. Это снижает нагрузку и экономит батарею.initialData, а остальное подтягивайте уже на клиенте. Пользователь видит UI быстрее, а тяжёлые payload’ы не дублируются.queryClient.resumePausedMutations() пригодится при восстановлении сети — можно вручную решать, когда именно продолжать «висящие» мутации (те, что упали из-за офлайна).aria-busy={query.isFetching} для доступности — ассистивные технологии поймут, что таблица обновляется.isPending, isSuccess, isError, isFetching, isRefetching, isPlaceholderData для точного UI.status === 'loading' больше нет — используйте isPending.!data && isPending, иначе показывайте спиннер рядом с существующими данными.@tanstack/react-query-devtools даже в проде (lazy + по хоткею) — там видно ключи, кеш, observers и причины рефетчей.queryClient.isFetching({ queryKey: ['todos'] }) помогает показывать глобальный индикатор загрузки без подписки на конкретные компоненты.dataUpdatedAt и status в Sentry-логах, чтобы понимать, почему пользователь видел устаревшие данные.queryCache в JSON. Это удобно для воспроизведения багов: вы можете сохранить состояние и реплейнуть его у себя локально.observers: возможно, другой компонент задаёт refetchInterval. queryClient.getQueryCache().find(...).observers покажет подписчиков.logger: { log, warn, error } в QueryClient — можно писать в консоль только в dev, а в прод отправлять ошибки в Sentry.['todo', id] и ['todos', id] — разные записи. Для детальных карточек используйте вложенные ключи ['todos', 'detail', id], чтобы легко инвалидировать группы.mutate() вызывайте queryClient.invalidateQueries({ queryKey: ['todos'], exact: false }) или setQueryData при наличии ID.refetch({ cancelRefetch: false }) позволит параллельно запустить несколько обновлений, если нужно сравнить ответы.select. Например, сортировка + агрегация там выполнятся один раз при изменении данных, а не при каждом ререндере.enabled: featureFlags.useNewApi && Boolean(id) — так при выключении флага запрос даже не появится в кеше.const keys = { todos: () => ['todos'], todo: (id) => ['todos', id] } — меньше опечаток и легче искать по коду.queryClient.refetchQueries({ type: 'inactive' }), если хотите прогреть фоновые запросы (например, при возврате из «Wizard» на дашборд).query.state.fetchFailureCount — по нему можно решать, показывать ли пользователю CTA «сообщить об ошибке» после N неудачных попыток.useMemo + query.data) и сбрасывайте их при dataUpdatedAt изменениях: это ускоряет ререндер таблиц на десятках тысяч строк.