TanStack Query 5: миграция с v4
TanStack Query 5 вышел больше года назад, и за это время я успела перевести два больших проекта с четвёрки. Если коротко: миграция чище, чем кажется на первый взгляд, но есть пара точек, где обязательно споткнёшься без подготовки. Здесь — мой чек-лист.
Что важного изменилось
Главные перемены в API:
- Убрали аргументы-позиционные у
useQuery. Теперь только объектная сигнатура. - Перерабтоан
onSuccessиonErrorна уровне query — большую часть сценариев нужно перенести вuseEffectили в селекторы. - Поведение
placeholderDataиkeepPreviousDataобъединено: новый шаблон черезplaceholderData: keepPreviousData. - Минимальная версия React — 18.
- Минимальная версия TypeScript — 5.0+.
Объектная сигнатура useQuery
В четвёрке можно было писать:
// v4 — больше не работает
const { data } = useQuery('posts', fetchPosts, { staleTime: 1000 });В пятёрке это превращается в:
// v5
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 1000,
});Это самая массовая правка. Я делала через codemod от команды TanStack Query — он покрыл 90% случаев. Оставшиеся 10% (где у нас были динамические ключи и условные опции) переписала руками.
pnpm dlx jscodeshift -t node_modules/@tanstack/react-query/codemods/v5/index.ts ./src --extensions=ts,tsxУдаление onSuccess/onError на query
Самое болезненное изменение. Раньше:
// v4
useQuery({
queryKey: ['user'],
queryFn: fetchUser,
onSuccess: (user) => analytics.track('user_loaded', user.id),
onError: (e) => toast.error(e.message),
});В пятёрке этих опций больше нет на уровне query. Команда обосновала удаление: коллбэки приводили к рассинхронизации с реальным состоянием данных и плохо работали с Suspense. Замены два:
Если эффект относится к успеху или ошибке текущего рендера — useEffect:
const { data, error, isSuccess, isError } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
useEffect(() => {
if (isSuccess) analytics.track('user_loaded', data.id);
}, [isSuccess, data]);
useEffect(() => {
if (isError) toast.error((error as Error).message);
}, [isError, error]);Для глобальных эффектов (логирование всех ошибок) — глобальный обработчик в QueryCache:
import { QueryCache, QueryClient } from '@tanstack/react-query';
const queryCache = new QueryCache({
onError: (error, query) => {
if (query.state.data !== undefined) return;
toast.error((error as Error).message);
},
});
export const queryClient = new QueryClient({ queryCache });В onSuccess/onError для мутаций изменений нет. Это полезно: для UI-эффектов после успеха действия они остаются.
placeholderData вместо keepPreviousData
В v4:
useQuery({ queryKey: ['posts', page], queryFn: () => fetchPage(page), keepPreviousData: true });В v5:
import { keepPreviousData } from '@tanstack/react-query';
useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPage(page),
placeholderData: keepPreviousData,
});Логика та же: при смене ключа предыдущие данные показываются как placeholder, пока не приедут новые. Просто переехало под общее API.
useMutation: тот же стиль
Объектная сигнатура везде, и это упрощает чтение:
const { mutate, isPending } = useMutation({
mutationFn: (post: Post) => api.posts.create(post),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['posts'] }),
});Поле isLoading переименовано в isPending. Это касается и useMutation, и useQuery: глобальное переименование во всех хуках. Замена через поиск и замену во всём проекте.
Возврат promise из mutate
Раньше mutate ничего не возвращал, и для ожидания результата нужно было использовать mutateAsync. В пятёрке mutate по-прежнему возвращает void, но mutateAsync остался и работает. Я в новом проекте всегда использую mutateAsync, чтобы строгий TypeScript не позволил случайно потерять обработку ошибок.
Suspense
В пятёрке useSuspenseQuery — отдельный хук, и его можно использовать с серверными компонентами в Next:
import { useSuspenseQuery } from '@tanstack/react-query';
const { data } = useSuspenseQuery({ queryKey: ['user'], queryFn: fetchUser });В отличие от обычного useQuery, тут нет состояний загрузки и ошибки — компонент приостанавливается до получения данных, а ошибка ловится ближайшим ErrorBoundary. У меня в проектах больше половины запросов в итоге переехали именно на этот хук — UI стал чище.
SSR и hydration
В v5 интеграция с серверным рендером упростилась через HydrationBoundary:
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
export default async function Page() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({ queryKey: ['user'], queryFn: fetchUser });
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UserCard />
</HydrationBoundary>
);
}Раньше это был Hydrate (без буквы B). Простое переименование. Codemod его подхватывает.
Что я бы сделала ещё в подготовке
- Прогнать codemod заранее на временной ветке. Посмотреть, что он не справился — туда уже руками.
- В CI выставить
npm-check-updatesилиrenovate, чтобы пакеты-сателлиты (типа@tanstack/react-query-devtools) обновились синхронно. - Заранее переписать тесты, в которых вы мокаете
useQuery: новая сигнатура там не зайдёт автоматически. - Если используется
react-query-persistorилиpersist-client— обновить на v5-совместимую версию.
Грабли, которые мы поймали
- Условные запросы. В v4 был трюк с
queryFn, который возвращалnullпри невалидных параметрах. В v5 это не работает — добавляйenabled: someConditionи не делай вид, что данных нет. - Глобальный
onErrorчерезdefaultOptions. Уехало вQueryCache. Если оставить старое — TS ругнётся, JS промолчит. - Кастомные хуки с дженериками. У нас был
useApi<T>(...), и сигнатуры дженериков дляuseQueryпоменялись. Codemod это не ловит, нужно править руками.
Стоит ли переходить
Да. Производительность кеша заметно лучше, ошибки ловятся чище, типы строже. У меня после миграции исчез один из самых противных багов — двойные запросы при ремонте StrictMode на dev-сервере. Не из-за самой миграции, а из-за чищения внутренней механики query.
Если у вас критичный продакт — делайте миграцию в отдельной ветке, гоните полный регресс, потом мерджите. На наших проектах это заняло день-два усилий. Самое долгое — переписать onSuccess/onError на уровне query: их обычно много, и каждый случай требует осмысленного решения, куда переехать (effect или global handler).
Что копать дальше
Если давно работали с TanStack Query — посмотрите изменения в queryClient.setQueryData: подсказки по типам стали жёстче, и местами это вытаскивает дыры в типах, которые до этого молчали. Полезно: ловит баги, которые раньше прятались в рантайме.