lenec ru

← все посты

TanStack Query 5: миграция с v4

10K

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 его подхватывает.

Что я бы сделала ещё в подготовке

  1. Прогнать codemod заранее на временной ветке. Посмотреть, что он не справился — туда уже руками.
  2. В CI выставить npm-check-updates или renovate, чтобы пакеты-сателлиты (типа @tanstack/react-query-devtools) обновились синхронно.
  3. Заранее переписать тесты, в которых вы мокаете useQuery: новая сигнатура там не зайдёт автоматически.
  4. Если используется 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: подсказки по типам стали жёстче, и местами это вытаскивает дыры в типах, которые до этого молчали. Полезно: ловит баги, которые раньше прятались в рантайме.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.