Отладка и диагностика проблем

TanStack Query скрывает значительную часть сложности работы с серверным состоянием: кэширование, синхронизацию, повторные запросы, инвалидацию, фоновое обновление, дедупликацию и управление жизненным циклом запросов. Именно поэтому многие ошибки оказываются неочевидными. Проблема может находиться не в самом запросе, а в ключах, staleTime, повторных рендерах, пересоздании QueryClient или неправильной работе invalidateQueries.

Отладка в TanStack Query строится вокруг нескольких ключевых направлений:

  • анализ состояния query;
  • контроль кэша;
  • диагностика повторных запросов;
  • отслеживание ререндеров;
  • анализ мутаций;
  • проверка invalidateQueries;
  • исследование жизненного цикла запросов;
  • использование Devtools;
  • логирование QueryClient;
  • анализ hydration/dehydration;
  • диагностика race conditions.

Devtools как основной инструмент диагностики

Пакет Devtools предоставляет визуальный интерфейс для анализа состояния запросов.

Установка:

npm install @tanstack/react-query-devtools

Подключение:

import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

function App() {
    return (
        <>
            <Routes />

            <ReactQueryDevtools initialIsOpen={false} />
        </>
    )
}

После подключения появляется панель, отображающая:

  • query keys;
  • состояние запросов;
  • кэшированные данные;
  • stale/fresh статус;
  • observers;
  • время жизни кэша;
  • состояние fetching;
  • retry;
  • ошибки;
  • время последнего обновления.

Анализ состояний запроса

Каждый query проходит несколько состояний:

const query = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
})

Состояния:

query.status

Возможные значения:

  • pending
  • error
  • success

Дополнительные флаги:

query.isPending
query.isLoading
query.isFetching
query.isError
query.isSuccess
query.isRefetching

Разница между isLoading и isFetching

Очень распространённая ошибка — неправильная интерпретация этих флагов.

isLoading

Активен только при первом запросе.

if (query.isLoading) {
    return <Spinner />
}

isFetching

Активен при любом сетевом запросе.

if (query.isFetching) {
    console.log('Идёт обновление')
}

Ситуация:

staleTime: 0

При возвращении во вкладку браузера:

query.isFetching === true

Но:

query.isLoading === false

Данные уже существуют в кэше.


Диагностика бесконечных запросов

Одна из наиболее частых проблем.

Причины:

  • нестабильный queryKey;
  • создание нового QueryClient;
  • ререндеры;
  • зависимость queryFn от изменяемых значений;
  • invalidateQueries внутри рендера;
  • бесконечные refetch.

Нестабильный queryKey

Проблемный пример:

useQuery({
    queryKey: ['users', { page: currentPage }],
    queryFn: fetchUsers,
})

Если объект создаётся заново на каждом рендере:

{ page: currentPage }

может происходить лишняя сериализация и повторные вычисления.

Опасный вариант:

queryKey: ['users', filters]

где filters постоянно пересоздаётся.

Правильный подход:

const filters = useMemo(() => ({
    page: currentPage,
}), [currentPage])

useQuery({
    queryKey: ['users', filters],
    queryFn: fetchUsers,
})

Создание QueryClient внутри компонента

Критическая ошибка:

function App() {
    const queryClient = new QueryClient()

    return (
        <QueryClientProvider client={queryClient}>
            <Routes />
        </QueryClientProvider>
    )
}

Каждый ререндер создаёт новый кэш.

Симптомы:

  • постоянные запросы;
  • потеря данных;
  • invalidateQueries не работает;
  • reset состояния.

Правильно:

const queryClient = new QueryClient()

function App() {
    return (
        <QueryClientProvider client={queryClient}>
            <Routes />
        </QueryClientProvider>
    )
}

Анализ queryKey

Devtools позволяет увидеть:

  • точный queryKey;
  • структуру ключей;
  • количество observers;
  • stale status;
  • время обновления.

Часто проблема скрыта именно в несовпадении ключей.

Пример:

['user', 1]

и

['users', 1]

Это два разных query.


Диагностика invalidateQueries

Очень частая проблема — invalidateQueries не обновляет данные.

Ошибка:

queryClient.invalidateQueries({
    queryKey: ['users'],
})

Но реальный queryKey:

['users', 'list']

или:

['user']

Необходимо проверять:

  • точное совпадение ключей;
  • partial matching;
  • active/inactive queries.

Exact matching

queryClient.invalidateQueries({
    queryKey: ['users'],
    exact: true,
})

Будет инвалидирован только:

['users']

Но не:

['users', 1]

Проверка cache state

Получение query из кэша:

const state = queryClient.getQueryState(['users'])

Проверка:

console.log(state)

Можно увидеть:

  • status;
  • fetchStatus;
  • dataUpdatedAt;
  • error;
  • fetchFailureCount.

Исследование query cache

Получение данных:

const data = queryClient.getQueryData(['users'])

Все queries:

const queries = queryClient.getQueryCache().getAll()

Полезно при сложной диагностике.


Подписка на события QueryCache

Инструмент глубокой диагностики.

queryClient.getQueryCache().subscribe((event) => {
    console.log(event)
})

Позволяет отслеживать:

  • создание query;
  • удаление;
  • обновление;
  • invalidate;
  • fetch;
  • observer changes.

Анализ retry

По умолчанию TanStack Query выполняет retry.

retry: 3

Симптом:

Вкладка Network показывает несколько одинаковых запросов.

Разработчик ошибочно считает, что приложение отправляет лишние запросы.

Проверка:

failureCount

или Devtools.


Отключение retry для диагностики

useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    retry: false,
})

Упрощает анализ ошибок.


Анализ staleTime

Ошибки staleTime встречаются постоянно.

staleTime: 0

Запрос считается устаревшим сразу.

Следствия:

  • refetch on focus;
  • refetch on mount;
  • refetch on reconnect.

Диагностика refetchOnWindowFocus

useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    refetchOnWindowFocus: true,
})

При переключении вкладок происходят запросы.

Часто воспринимается как баг.

Для проверки:

refetchOnWindowFocus: false

Анализ gcTime

Ранее параметр назывался cacheTime.

gcTime: 1000 * 60 * 5

Если время слишком маленькое:

gcTime: 0

Кэш удаляется почти мгновенно.

Симптомы:

  • повторные запросы;
  • отсутствие кеширования;
  • потеря данных между страницами.

Отладка мутаций

Devtools отображает mutations отдельно от queries.

Можно анализировать:

  • pending;
  • success;
  • error;
  • retry;
  • paused.

Диагностика optimistic update

Ошибка:

onMutate: async () => {
    queryClient.setQueryData(...)
}

Но rollback отсутствует.

Правильный подход:

onMutate: async () => {
    const previous = queryClient.getQueryData(['todos'])

    queryClient.setQueryData(['todos'], updater)

    return { previous }
},

onError: (error, variables, context) => {
    queryClient.setQueryData(
        ['todos'],
        context.previous
    )
}

Анализ race conditions

Ситуация:

  • отправлен запрос A;
  • затем запрос B;
  • B завершился раньше A;
  • старые данные перезаписали новые.

TanStack Query частично решает проблему через cancellation.


Отмена запросов

queryFn: async ({ signal }) => {
    const response = await fetch('/api/users', {
        signal,
    })

    return response.json()
}

Если signal игнорируется, отмена не работает.


Диагностика select

Проблема:

select: (data) => ({
    ...data
})

Каждый вызов создаёт новый объект.

Следствия:

  • ререндеры;
  • потеря referential equality.

Лучше:

select: (data) => data.items

Отслеживание ререндеров

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

console.count('Component render')

или React DevTools Profiler.


notifyOnChangeProps

Иногда компонент ререндерится из-за изменения внутренних свойств query.

useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    notifyOnChangeProps: ['data'],
})

Компонент будет реагировать только на изменения data.


Диагностика enabled

Частая ошибка:

enabled: !!userId

Но:

userId = 0

Запрос не выполняется.

Лучше:

enabled: userId !== undefined

Анализ dependent queries

Проблема:

const userQuery = useQuery(...)
const postsQuery = useQuery(...)

postsQuery стартует раньше userQuery.

Правильно:

const postsQuery = useQuery({
    queryKey: ['posts', userQuery.data?.id],
    queryFn: fetchPosts,
    enabled: !!userQuery.data,
})

Диагностика hydration

При SSR возможны ошибки:

  • двойные запросы;
  • рассинхронизация;
  • отсутствие данных после hydration.

Проверка:

dehydrate(queryClient)

и:

<Hydrate state={pageProps.dehydratedState}>

Ошибки сериализации

Плохой queryKey:

queryKey: ['users', new Date()]

или:

queryKey: ['users', function() {}]

Ключи должны быть сериализуемыми.


Логирование QueryClient

Создание кастомного logger:

const queryClient = new QueryClient({
    logger: {
        log: console.log,
        warn: console.warn,
        error: console.error,
    },
})

Полезно при сложной отладке.


Проверка сетевых ошибок

Полезно анализировать:

error.message

или:

error.response

если используется axios.


Глобальная обработка ошибок

const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            onError: (error) => {
                console.error(error)
            },
        },
    },
})

Диагностика duplicate queries

Проблема:

useQuery({
    queryKey: ['users'],
})

и:

useQuery({
    queryKey: ['users '],
})

Лишний пробел создаёт новый query.


Анализ observers

Devtools показывает количество observers.

Если observers:

0

query становится inactive.

Позже может быть удалён gc collector.


Проверка fetchStatus

query.fetchStatus

Возможные значения:

  • fetching
  • paused
  • idle

Это отдельное состояние от status.


Диагностика offline mode

Если запросы зависают:

fetchStatus === 'paused'

Возможна проблема networkMode.


networkMode

networkMode: 'always'

или:

networkMode: 'offlineFirst'

Неправильная конфигурация может блокировать запросы.


Использование setLogger

Для глубокой диагностики:

import { setLogger } from '@tanstack/react-query'

setLogger({
    log: console.log,
    warn: console.warn,
    error: console.error,
})

Анализ memory leaks

Проблема:

  • огромное количество query;
  • кэш не очищается;
  • рост памяти.

Проверка:

queryClient.getQueryCache().getAll()

Причина часто связана с динамическими queryKey:

['users', Date.now()]

Создаётся бесконечное количество query.


Диагностика useInfiniteQuery

Типичная ошибка:

getNextPageParam: () => true

Бесконечная пагинация никогда не заканчивается.

Правильно:

getNextPageParam: (lastPage) => {
    return lastPage.nextCursor
}

Анализ refetchInterval

refetchInterval: 1000

Запрос выполняется каждую секунду.

Иногда разработчик забывает отключить polling.


Проверка invalidate после mutation

Ошибка:

onSuccess: () => {
    queryClient.invalidateQueries({
        queryKey: ['todo']
    })
}

Но query использует:

['todos']

Кэш не обновляется.


Диагностика suspense

При использовании Suspense ошибки могут скрываться внутри boundary.

Важно анализировать:

  • ErrorBoundary;
  • QueryErrorResetBoundary;
  • reset behavior.

QueryErrorResetBoundary

<QueryErrorResetBoundary>
    {({ reset }) => (
        <ErrorBoundary onRe set={reset}>
            <Page />
        </ErrorBoundary>
    )}
</QueryErrorResetBoundary>

Позволяет корректно сбрасывать ошибки запросов.


Проверка времени обновления

query.dataUpdatedAt

Позволяет определить:

  • когда обновлялись данные;
  • действительно ли произошёл refetch;
  • используется ли кэш.

Диагностика stale data

Ситуация:

setQueryData()

обновил кэш, но UI не изменился.

Причины:

  • мутация объекта;
  • отсутствие нового reference;
  • select memoization;
  • notifyOnChangeProps.

Проблемный пример:

oldData.items.push(newItem)

return oldData

Правильно:

return {
    ...oldData,
    items: [...oldData.items, newItem],
}

Проверка deduplication

TanStack Query автоматически объединяет одинаковые запросы.

Если deduplication не работает, причины обычно:

  • разные queryKey;
  • разные queryFn;
  • разные QueryClient;
  • race conditions.

Отладка через browser Network

Важно сопоставлять:

  • реальный HTTP-запрос;
  • query state;
  • retry;
  • background refetch;
  • invalidate.

Именно Network часто показывает реальную картину поведения приложения.


Стратегия комплексной диагностики

Практический порядок анализа:

  1. Проверка queryKey.
  2. Проверка QueryClient.
  3. Анализ Devtools.
  4. Проверка staleTime.
  5. Проверка retry.
  6. Анализ invalidateQueries.
  7. Проверка ререндеров.
  8. Анализ gcTime.
  9. Проверка enabled.
  10. Анализ select.
  11. Проверка fetchStatus.
  12. Анализ Network.
  13. Проверка hydration.
  14. Анализ observers.
  15. Проверка optimistic updates.