Гидратация и дегидратация

В контексте TanStack Query гидратация и дегидратация представляют собой механизм переноса состояния кеша между различными средами выполнения приложения. Чаще всего этот механизм используется при серверном рендеринге, статической генерации страниц, предварительной загрузке данных и восстановлении состояния на клиенте.

Дегидратация (dehydrate) — процесс сериализации состояния QueryClient в обычный JSON-совместимый объект.

Гидратация (hydrate) — процесс восстановления этого состояния внутри нового экземпляра QueryClient.

Механизм позволяет:

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

Проблема повторных запросов без гидратации

При обычном клиентском рендеринге последовательность выглядит следующим образом:

  1. React-компонент монтируется.
  2. useQuery обнаруживает отсутствие кеша.
  3. Выполняется HTTP-запрос.
  4. Интерфейс показывает состояние загрузки.
  5. Данные сохраняются в кеш.

При SSR без гидратации возникает дублирование:

  1. Сервер получает данные.
  2. Сервер рендерит HTML.
  3. Браузер получает страницу.
  4. Клиентский React запускается заново.
  5. useQuery снова выполняет запрос.

В результате:

  • запросы выполняются дважды;
  • растёт нагрузка на API;
  • появляется лишняя задержка;
  • пользователь может видеть повторную загрузку.

Гидратация устраняет эту проблему.


Архитектура процесса

Полный цикл выглядит так:

На сервере

  1. Создаётся QueryClient.
  2. Выполняется prefetchQuery.
  3. Состояние кеша сериализуется через dehydrate.
  4. Данные встраиваются в HTML.

На клиенте

  1. Создаётся новый QueryClient.
  2. Выполняется hydrate.
  3. useQuery получает готовые данные из кеша.
  4. Повторный запрос не требуется.

Установка и подключение

Для SSR используется пакет:

npm install @tanstack/react-query

В современных версиях дополнительный пакет гидратации больше не требуется.


Базовая структура QueryClient

Обычно создаётся отдельный экземпляр клиента:

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

export const queryClient = new QueryClient()

При SSR нельзя использовать один глобальный клиент для всех запросов сервера.

Каждый HTTP-запрос должен создавать собственный экземпляр QueryClient.


Дегидратация кеша

Предварительная загрузка данных

import {
  QueryClient,
  dehydrate
} from '@tanstack/react-query'

const queryClient = new QueryClient()

await queryClient.prefetchQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts
})

const dehydratedState = dehydrate(queryClient)

После вызова dehydrate создаётся сериализуемое состояние:

{
  queries: [...],
  mutations: [...]
}

Этот объект можно:

  • встроить в HTML;
  • отправить через API;
  • передать в props;
  • сохранить во временное хранилище.

Что сохраняет dehydrate

В сериализованное состояние входят:

Состояние запросов

  • queryKey
  • данные
  • статус
  • timestamps
  • ошибки
  • метаданные

Состояние мутаций

  • mutation cache
  • статус мутаций
  • variables
  • metadata

Служебные параметры

  • время обновления;
  • stale state;
  • fetch status.

Пример структуры дегидратированного состояния

{
  queries: [
    {
      queryKey: ['posts'],
      state: {
        data: [...],
        status: 'success',
        fetchStatus: 'idle'
      }
    }
  ]
}

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


Гидратация состояния

На клиенте выполняется восстановление кеша.

import {
  QueryClient,
  QueryClientProvider,
  hydrate
} from '@tanstack/react-query'

const queryClient = new QueryClient()

hydrate(queryClient, dehydratedState)

После гидратации:

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

Использование компонента HydrationBoundary

Современный подход использует HydrationBoundary.

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

Пример:

<QueryClientProvider client={queryClient}>
  <HydrationBoundary state={dehydratedState}>
    <App />
  </HydrationBoundary>
</QueryClientProvider>

HydrationBoundary автоматически вызывает hydrate.


SSR в Next.js App Router

Серверный компонент

import {
  QueryClient,
  dehydrate,
  HydrationBoundary
} from '@tanstack/react-query'

export default async function Page() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}

Клиентский компонент

'use client'

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

export function Posts() {
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts
  })

  return (
    <div>
      {data.map(post => (
        <div key={post.id}>
          {post.title}
        </div>
      ))}
    </div>
  )
}

После гидратации запрос повторно не выполняется.


SSR в Next.js Pages Router

getServerSideProps

import {
  QueryClient,
  dehydrate
} from '@tanstack/react-query'

export async function getServerSideProps() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts
  })

  return {
    props: {
      dehydratedState: dehydrate(queryClient)
    }
  }
}

Подключение на клиенте

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

export default function Page({ dehydratedState }) {
  return (
    <HydrationBoundary state={dehydratedState}>
      <Posts />
    </HydrationBoundary>
  )
}

Предварительная загрузка нескольких запросов

Можно гидратировать сразу несколько кешей.

await Promise.all([
  queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts
  }),
  queryClient.prefetchQuery({
    queryKey: ['users'],
    queryFn: fetchUsers
  }),
  queryClient.prefetchQuery({
    queryKey: ['comments'],
    queryFn: fetchComments
  })
])

После гидратации все данные уже доступны.


Параллельная загрузка

Параллельный prefetchQuery критически важен для SSR.

Неправильный вариант:

await queryClient.prefetchQuery(...)
await queryClient.prefetchQuery(...)
await queryClient.prefetchQuery(...)

Правильный вариант:

await Promise.all([
  queryClient.prefetchQuery(...),
  queryClient.prefetchQuery(...),
  queryClient.prefetchQuery(...)
])

Это существенно уменьшает TTFB.


staleTime и гидратация

После восстановления кеша TanStack Query проверяет актуальность данных.

Если staleTime равен 0, запрос может немедленно обновиться.

useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  staleTime: 1000 * 60
})

В этом случае данные считаются актуальными одну минуту.


Поведение при refetchOnMount

Даже после гидратации возможен повторный запрос.

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

Чтобы полностью исключить повторный fetch:

useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  staleTime: 60000,
  refetchOnMount: false
})

selective dehydration

Можно исключать часть запросов из сериализации.

dehydrate(queryClient, {
  shouldDehydrateQuery: query =>
    query.queryKey[0] !== 'admin'
})

Это позволяет:

  • уменьшить размер HTML;
  • скрыть приватные данные;
  • исключить временные запросы.

Исключение ошибок из гидратации

По умолчанию запросы с ошибками не сериализуются.

Поведение можно изменить:

dehydrate(queryClient, {
  shouldDehydrateQuery: () => true
})

Сериализация ошибок

Объекты Error не сериализуются корректно через JSON.

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

{
  error: new Error('Network failed')
}

После сериализации теряются:

  • prototype;
  • stack;
  • методы;
  • instanceof.

Часто ошибки преобразуют вручную:

{
  message: error.message
}

Ограничения сериализации

dehydrate работает только с JSON-совместимыми структурами.

Проблемы вызывают:

  • Map
  • Set
  • Date
  • BigInt
  • функции
  • классы
  • circular references

Обработка Date

Дата превращается в строку.

{
  createdAt: "2026-05-24T12:00:00.000Z"
}

После гидратации:

typeof createdAt === 'string'

Для восстановления:

new Date(createdAt)

Кастомная сериализация

Иногда используется superjson.

npm install superjson

Пример:

import superjson from 'superjson'

const serialized = superjson.serialize(
  dehydrate(queryClient)
)

Восстановление:

const dehydratedState =
  superjson.deserialize(serialized)

Это позволяет сохранять:

  • Date
  • Map
  • Set
  • сложные типы.

PrefetchQuery и fetchQuery

prefetchQuery

await queryClient.prefetchQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts
})

Не выбрасывает ошибку наружу.


fetchQuery

await queryClient.fetchQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts
})

Возвращает данные и выбрасывает ошибки.


Когда использовать fetchQuery

fetchQuery полезен:

  • при SSR-валидации;
  • для middleware;
  • при условном redirect;
  • для проверки доступа.

Infinite Queries и гидратация

Infinite Query полностью поддерживает дегидратацию.

await queryClient.prefetchInfiniteQuery({
  queryKey: ['feed'],
  queryFn: fetchFeed,
  initialPageParam: 0
})

Сохраняются:

  • pages;
  • pageParams;
  • курсоры;
  • pagination state.

Структура infinite query

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

После гидратации пагинация продолжает работать без повторной загрузки.


Гидратация мутаций

TanStack Query способен сериализовать mutation cache.

Это полезно:

  • при offline-first;
  • для retry после reconnect;
  • при persistence.

PersistQueryClient и гидратация

Гидратация тесно связана с persistence.

Например:

npm install @tanstack/query-persist-client-core

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

  • в localStorage;
  • IndexedDB;
  • AsyncStorage.

После перезапуска приложения выполняется hydrate из persistence layer.


PersistQueryClientProvider

<PersistQueryClientProvider
  client={queryClient}
  persistOptions={{ persister }}
>
  <App />
</PersistQueryClientProvider>

Offline-first приложения

Гидратация особенно важна для offline-режима.

Сценарий:

  1. Данные загружаются.
  2. Кеш сохраняется локально.
  3. Пользователь закрывает приложение.
  4. После запуска hydrate восстанавливает состояние.
  5. Интерфейс работает мгновенно.

Memory leak при SSR

Одна из самых опасных ошибок — повторное использование QueryClient между запросами.

Неправильно:

const queryClient = new QueryClient()

На уровне модуля сервера.

Проблемы:

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

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

Экземпляр должен создаваться внутри запроса.

export async function getServerSideProps() {
  const queryClient = new QueryClient()

  // ...
}

Очистка кеша после SSR

Иногда требуется ручная очистка.

queryClient.clear()

Особенно актуально:

  • для long-running Node.js серверов;
  • при кастомном SSR;
  • в express middleware.

Размер dehydratedState

Большой кеш может значительно увеличивать HTML.

Проблемы:

  • медленная передача;
  • высокий memory usage;
  • рост hydration cost;
  • ухудшение TTFB.

Оптимизация размера состояния

Исключение ненужных запросов

shouldDehydrateQuery

Сокращение данных API

Лучше:

{
  id,
  title
}

Хуже:

{
  hugeNestedObject
}

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

useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  select: data =>
    data.map(post => ({
      id: post.id,
      title: post.title
    }))
})

SSR и Suspense

Hydration отлично работает вместе с Suspense.

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

Если данные уже гидратированы:

  • Suspense fallback не отображается;
  • интерфейс рендерится сразу.

Потоковая гидратация

В React Server Components возможна частичная потоковая передача.

TanStack Query постепенно интегрируется с streaming SSR.

Это позволяет:

  • отправлять HTML частями;
  • гидратировать сегменты независимо;
  • ускорять интерактивность.

Гидратация в Remix

Пример серверной загрузки:

const queryClient = new QueryClient()

await queryClient.prefetchQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts
})

return json({
  dehydratedState: dehydrate(queryClient)
})

Гидратация в Nuxt + React islands

При гибридных архитектурах гидратация позволяет:

  • передавать серверный кеш;
  • избегать двойных fetch;
  • ускорять islands hydration.

Devtools и гидратация

Devtools позволяют наблюдать:

  • hydrated queries;
  • timestamps;
  • stale status;
  • cache restoration.

После SSR запросы сразу появляются в кеше.


Частые ошибки

Повторный refetch после SSR

Причина:

staleTime: 0

Слишком большой HTML

Причина:

  • огромные API-ответы;
  • сериализация лишних данных.

Утечки памяти

Причина:

  • глобальный QueryClient на сервере.

Несовпадение данных

Причина:

  • разные queryKey;
  • разная логика queryFn;
  • изменение структуры данных.

Проверка гидратации

Признаки успешной гидратации:

  • отсутствует loading state;
  • нет повторного fetch;
  • данные появляются мгновенно;
  • Devtools показывают готовый cache state.

Жизненный цикл hydrated query

  1. Сервер выполняет fetch.
  2. Query помещается в cache.
  3. Выполняется dehydrate.
  4. JSON передаётся клиенту.
  5. hydrate восстанавливает cache.
  6. useQuery читает готовые данные.
  7. staleTime определяет необходимость refetch.

Различие между initialData и hydrate

initialData

useQuery({
  initialData: data
})

Передаёт данные локально.


hydrate

hydrate(queryClient, state)

Восстанавливает полноценный кеш.


Преимущества hydrate над initialData

Hydrate сохраняет:

  • timestamps;
  • stale state;
  • metadata;
  • pagination;
  • cache consistency;
  • mutation state.

Когда initialData лучше

initialData подходит:

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

Когда hydrate обязателен

Hydration необходим:

  • при полноценном SSR;
  • сложном кешировании;
  • Infinite Query;
  • persistence;
  • offline-first;
  • streaming;
  • shared cache;
  • многостраничной предзагрузке.