Производительность

Производительность в TanStack Query строится вокруг нескольких ключевых механизмов:

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

Библиотека решает сразу две проблемы:

  1. Снижает нагрузку на сервер.
  2. Уменьшает количество ненужных ререндеров React-компонентов.

В крупных приложениях именно эти два фактора чаще всего становятся причиной деградации интерфейса.


Как работает кеш запросов

В основе TanStack Query находится QueryCache.

Каждый запрос хранится по уникальному queryKey.

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

После выполнения запроса данные попадают в кеш:

{
    queryKey: ['users'],
    state: {
        data,
        status,
        error,
        fetchStatus
    }
}

Если другой компонент использует такой же queryKey, повторный запрос не выполняется.

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

Оба компонента получают одни и те же данные из кеша.

Это:

  • уменьшает количество HTTP-запросов;
  • сокращает время загрузки;
  • снижает нагрузку на backend;
  • устраняет дублирование состояния.

Дедупликация запросов

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

Если одновременно вызываются:

useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts
})

в нескольких местах приложения, библиотека выполняет только один HTTP-запрос.

Остальные подписчики получают тот же Promise.

Без дедупликации возможна ситуация:

Компонент A -> GET /posts
Компонент B -> GET /posts
Компонент C -> GET /posts

TanStack Query преобразует это в:

GET /posts

с общим результатом для всех компонентов.

Это особенно важно:

  • в dashboard-интерфейсах;
  • в SSR;
  • в микрофронтендах;
  • при сложных деревьях компонентов.

staleTime и производительность

staleTime определяет, сколько времени данные считаются свежими.

useQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
    staleTime: 1000 * 60 * 5
})

В течение 5 минут:

  • повторный mount компонента не вызывает запрос;
  • refetch не выполняется автоматически;
  • данные читаются из кеша.

Влияние staleTime

Маленький staleTime:

staleTime: 0

означает:

  • частые refetch;
  • повышенную сетевую нагрузку;
  • больше ререндеров.

Большой staleTime:

staleTime: Infinity

минимизирует запросы, но повышает риск устаревших данных.


Правильный выбор staleTime

Часто изменяющиеся данные

staleTime: 5000

Подходит для:

  • уведомлений;
  • чатов;
  • статистики;
  • биржевых данных.

Умеренно изменяющиеся данные

staleTime: 1000 * 60

Подходит для:

  • профилей;
  • комментариев;
  • каталогов.

Почти статические данные

staleTime: Infinity

Подходит для:

  • ролей;
  • конфигурации;
  • языков;
  • справочников.

gcTime и очистка памяти

В TanStack Query v5 параметр cacheTime был переименован в gcTime.

useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    gcTime: 1000 * 60 * 10
})

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

  • query становится inactive;
  • запускается таймер garbage collection;
  • кеш удаляется через gcTime.

Маленький gcTime

gcTime: 0

Плюсы:

  • меньше потребление памяти.

Минусы:

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

Большой gcTime

gcTime: Infinity

Плюсы:

  • мгновенное получение данных;
  • минимизация запросов.

Минусы:

  • рост памяти;
  • накопление большого кеша.

Структурное сравнение данных

TanStack Query использует structural sharing.

Если часть объекта не изменилась, ссылка сохраняется.

Пример:

{
    users: [
        { id: 1, name: 'Alex' },
        { id: 2, name: 'John' }
    ]
}

После обновления:

{
    users: [
        { id: 1, name: 'Alex' },
        { id: 2, name: 'Bob' }
    ]
}

Объект пользователя с id: 1 сохранит старую ссылку.

Это уменьшает:

  • количество ререндеров;
  • нагрузку на reconciliation React;
  • объем мусора в памяти.

notifyOnChangeProps

По умолчанию компонент ререндерится при изменении любых полей query state.

Можно ограничить отслеживание:

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

Теперь компонент реагирует только на изменения data.

Это снижает количество ререндеров при изменении:

  • isFetching;
  • fetchStatus;
  • errorUpdatedAt;
  • других служебных полей.

select и оптимизация вычислений

select позволяет извлекать только нужную часть данных.

useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    select: (data) => data.items
})

Компонент будет зависеть только от items.

Без select:

data.meta
data.pagination
data.permissions

тоже участвуют в сравнении.


Проблема тяжелого select

Опасная конструкция:

select: (data) => {
    return data.items.map(item => ({
        ...item,
        fullName: `${item.first} ${item.last}`
    }))
}

select вызывается при каждом обновлении query.

Если данных много:

  • растет CPU usage;
  • увеличиваются ререндеры;
  • ухудшается responsiveness.

Мемоизация select

Лучше выносить тяжелую обработку:

const transformUsers = memoize((users) => {
    return users.map(user => ({
        ...user,
        fullName: `${user.first} ${user.last}`
    }))
})
select: (data) => transformUsers(data.items)

placeholderData и начальная производительность

placeholderData позволяет мгновенно показать данные до завершения запроса.

useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    placeholderData: []
})

Преимущества:

  • отсутствие пустых экранов;
  • меньше layout shift;
  • быстрее perceived performance.

initialData

initialData отличается от placeholderData.

useQuery({
    queryKey: ['settings'],
    queryFn: fetchSettings,
    initialData: cachedSettings
})

initialData попадает в настоящий кеш.

placeholderData — временное значение.


keepPreviousData

При пагинации часто возникает мерцание.

Без оптимизации:

Старая страница -> loading -> новая страница

С keepPreviousData:

useQuery({
    queryKey: ['posts', page],
    queryFn: () => fetchPosts(page),
    placeholderData: keepPreviousData
})

старые данные сохраняются до прихода новых.

Это:

  • уменьшает визуальные скачки;
  • улучшает UX;
  • снижает ощущение задержек.

Фоновый refetch

TanStack Query умеет обновлять данные в фоне.

refetchOnWindowFocus: true

При возврате во вкладку:

  • показываются старые данные;
  • запрос выполняется в фоне;
  • интерфейс обновляется после ответа.

Это быстрее, чем:

loading -> empty state -> data

Интервальный polling

useQuery({
    queryKey: ['stats'],
    queryFn: fetchStats,
    refetchInterval: 5000
})

Важно избегать слишком маленьких интервалов.

Плохой вариант:

refetchInterval: 500

Проблемы:

  • высокая нагрузка;
  • постоянные ререндеры;
  • сетевой spam;
  • перегрев CPU.

refetchIntervalInBackground

useQuery({
    queryKey: ['stats'],
    queryFn: fetchStats,
    refetchInterval: 5000,
    refetchIntervalInBackground: false
})

Когда вкладка скрыта:

  • polling останавливается;
  • уменьшается расход ресурсов.

Prefetching

Предзагрузка данных уменьшает задержки интерфейса.

queryClient.prefetchQuery({
    queryKey: ['post', id],
    queryFn: () => fetchPost(id)
})

Когда пользователь открывает страницу:

  • данные уже в кеше;
  • loading часто отсутствует.

Hover-prefetch

Популярная техника:

const handleMouseEnter = () => {
    queryClient.prefetchQuery({
        queryKey: ['product', id],
        queryFn: () => fetchProduct(id)
    })
}

Предзагрузка начинается при наведении мыши.


Infinite Query и производительность

useInfiniteQuery требует осторожности.

Каждая страница хранится в кеше:

{
    pages: [...],
    pageParams: [...]
}

При бесконечном скролле память может расти бесконтрольно.


Ограничение размера infinite cache

Иногда необходимо вручную обрезать страницы:

queryClient.setQueryData(
    ['feed'],
    (data) => ({
        ...data,
        pages: data.pages.slice(-5),
        pageParams: data.pageParams.slice(-5)
    })
)

Это уменьшает:

  • потребление RAM;
  • время сериализации;
  • размер persisted cache.

Optimistic Updates

Optimistic updates уменьшают perceived latency.

onMutate: async (newTodo) => {
    await queryClient.cancelQueries(['todos'])

    const previousTodos =
        queryClient.getQueryData(['todos'])

    queryClient.setQueryData(
        ['todos'],
        (old) => [...old, newTodo]
    )

    return { previousTodos }
}

Интерфейс обновляется мгновенно без ожидания сервера.


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

TanStack Query поддерживает AbortController.

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

    return response.json()
}

При unmount:

  • запрос отменяется;
  • уменьшается нагрузка;
  • предотвращаются race condition.

Race Conditions

Проблема:

Запрос A стартовал
Запрос B стартовал
B завершился
A завершился

Старые данные могут перезаписать новые.

TanStack Query умеет корректно управлять такими ситуациями через:

  • cancellation;
  • query state tracking;
  • request deduplication.

Suspense и производительность

Suspense уменьшает количество ручной логики:

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

Преимущества:

  • меньше условных ререндеров;
  • проще дерево компонентов;
  • централизованный loading state.

Split Queries

Плохая практика:

useQuery({
    queryKey: ['dashboard'],
    queryFn: fetchDashboard
})

Если API возвращает:

{
    users,
    stats,
    notifications,
    charts,
    messages
}

любое изменение вызывает обновление всего query.


Декомпозиция запросов

Лучше:

useUsersQuery()
useStatsQuery()
useNotificationsQuery()

Преимущества:

  • точечные ререндеры;
  • меньший объем обновлений;
  • независимый staleTime;
  • независимый refetch.

Нормализация данных

TanStack Query не нормализует данные автоматически.

Плохой вариант:

['posts']
['post', id]

могут хранить разные версии одного объекта.


Ручная синхронизация кеша

После mutation:

queryClient.setQueryData(
    ['post', post.id],
    post
)

или:

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

invalidateQueries и производительность

Избыточная invalidation — одна из самых частых проблем.

Плохо:

queryClient.invalidateQueries()

Это может вызвать:

  • десятки refetch;
  • массовые ререндеры;
  • скачки CPU.

Точная invalidation

Лучше:

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

или:

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

Batch Updates

TanStack Query умеет группировать обновления.

Несколько изменений кеша:

queryClient.setQueryData(...)
queryClient.setQueryData(...)
queryClient.invalidateQueries(...)

могут быть объединены в один цикл уведомлений.

Это снижает количество ререндеров React.


Persisted Cache

Кеш можно сохранять:

  • в localStorage;
  • IndexedDB;
  • AsyncStorage.
persistQueryClient({
    queryClient,
    persister
})

Преимущества:

  • мгновенный startup;
  • меньше запросов;
  • offline support.

Проблемы большого persisted cache

Слишком большой кеш вызывает:

  • медленную сериализацию;
  • долгую гидрацию;
  • рост памяти браузера.

Важно:

  • ограничивать gcTime;
  • очищать старые query;
  • не хранить гигантские datasets.

SSR и hydration

Во время SSR данные могут быть подготовлены заранее.

dehydrate(queryClient)

На клиенте:

hydrate(queryClient, dehydratedState)

Это устраняет:

  • двойные запросы;
  • лишние loading state;
  • скачки layout.

React.memo и TanStack Query

Даже при использовании TanStack Query лишние ререндеры возможны.

export default React.memo(UserCard)

особенно полезен для:

  • списков;
  • таблиц;
  • карточек;
  • virtualized UI.

Виртуализация списков

TanStack Query не решает проблему огромных DOM-деревьев.

Для больших списков необходима виртуализация:

  • react-window;
  • react-virtual;
  • react-virtualized.

Performance bottleneck в Query Keys

Плохой queryKey:

queryKey: ['users', filters]

если filters создается заново:

{
    sort: 'name'
}

на каждом рендере.

Это приводит к:

  • новым query;
  • потере кеша;
  • лишним запросам.

Стабильные queryKey

Лучше:

const filters = useMemo(() => ({
    sort: 'name'
}), [])

Query Observer

Каждый useQuery создает observer.

Observer отслеживает:

  • изменения состояния;
  • подписчиков;
  • refetch;
  • lifecycle query.

Большое количество observers увеличивает нагрузку.


Избыточные useQuery

Плохо:

items.map(item => (
    <Row key={item.id} id={item.id} />
))

если внутри каждого Row:

useQuery(...)

При сотнях элементов появляются:

  • сотни observers;
  • множество подписок;
  • дорогие обновления.

Агрегация запросов

Иногда эффективнее загрузить данные заранее:

useQuery({
    queryKey: ['rows'],
    queryFn: fetchRows
})

и передавать их вниз через props.


Devtools и производительность

TanStack Query Devtools:

  • показывает refetch;
  • отображает cache lifecycle;
  • помогает находить лишние запросы;
  • выявляет проблемы invalidation.

В production Devtools обычно отключаются.


Метрики производительности

При анализе TanStack Query отслеживаются:

  • количество запросов;
  • количество refetch;
  • число observers;
  • размер кеша;
  • время hydration;
  • количество ререндеров;
  • потребление памяти;
  • network waterfall.

Типичные ошибки производительности

Бесконечные refetch

refetchOnMount: true
refetchOnWindowFocus: true
staleTime: 0

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

queryKey: ['posts', {}]

Огромные query responses

GET /all-data

Глобальная invalidation

invalidateQueries()

Слишком маленький refetchInterval

refetchInterval: 1000

Отсутствие pagination

GET /posts

для десятков тысяч записей.


Стратегия высокопроизводительного приложения

Обычно эффективная архитектура включает:

  • длинный staleTime;
  • умеренный gcTime;
  • selective invalidation;
  • prefetching;
  • split queries;
  • virtualization;
  • optimistic updates;
  • persisted cache;
  • SSR hydration;
  • memoization;
  • ограничение polling;
  • стабильные queryKey.

Именно комбинация этих механизмов позволяет TanStack Query обслуживать крупные приложения с тысячами запросов и сложным UI без критической деградации производительности.