RTK Query в SSR приложениях

SSR (Server-Side Rendering) накладывает на RTK Query ряд особенностей, связанных с жизненным циклом запроса, изоляцией состояния Redux на каждый запрос пользователя и необходимостью корректной гидрации клиентского кэша. Основная цель интеграции — обеспечить одинаковые данные на сервере и клиенте без повторного запроса там, где это возможно, сохранив при этом преимущества кэширования RTK Query.

RTK Query опирается на Redux store как на источник единого состояния. В SSR-приложениях это означает, что:

  • каждый серверный запрос должен получать новый экземпляр store
  • данные, загруженные на сервере, сериализуются и передаются на клиент
  • клиент восстанавливает кэш RTK Query из гидратированного состояния

Ключевой момент заключается в том, что RTK Query не хранит состояние вне Redux. Поэтому SSR-интеграция сводится к правильной организации store и синхронизации кэша.

Создание store для каждого запроса

В SSR нельзя использовать глобальный store. Причина — конкуренция запросов и утечки данных между пользователями.

Типовая фабрика store:

import { configureStore } from '@reduxjs/toolkit'
import { api } from './api'

export function createStore(preloadedState) {
  return configureStore({
    reducer: {
      [api.reducerPath]: api.reducer,
    },
    middleware: (getDefaultMiddleware) =>
      getDefaultMiddleware().concat(api.middleware),
    preloadedState,
  })
}

Каждый запрос на сервере вызывает createStore, обеспечивая изоляцию состояния RTK Query.

Prefetch данных на сервере

Основной механизм SSR с RTK Query — ручной запуск запросов до рендера React.

RTK Query предоставляет .initiate() для dispatch запросов без React-хуков.

import { api } from './api'

export async function loadData(store) {
  const promise = store.dispatch(
    api.endpoints.getPosts.initiate()
  )

  await promise
  store.dispatch(api.util.getRunningQueriesThunk())
}

Важный момент

initiate() возвращает thunk, который можно ожидать через .unwrap() или через Promise, доступный в dispatch.

await store.dispatch(api.endpoints.getPosts.initiate()).unwrap()

Это гарантирует, что к моменту рендера HTML данные уже находятся в кэше RTK Query.

Интеграция с Next.js (pages router)

В классическом Next.js SSR используется getServerSideProps.

export async function getServerSideProps() {
  const store = createStore()

  await store.dispatch(api.endpoints.getPosts.initiate())

  return {
    props: {
      initialReduxState: store.getState(),
    },
  }
}

На клиенте состояние передается в store как preloadedState.

const store = createStore(initialReduxState)

Гидрация RTK Query кэша

RTK Query хранит данные в slice api.reducerPath. При гидрации важно восстановить именно этот сегмент состояния.

import { Provider } from 'react-redux'

export function App({ store }) {
  return (
    <Provider store={store}>
      <Root />
    </Provider>
  )
}

Redux Toolkit автоматически подхватывает preloadedState, и RTK Query считает кэш уже заполненным.

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

Без дополнительных настроек RTK Query может повторно запрашивать данные после гидрации. Это связано с тем, что:

  • данные считаются устаревшими
  • отсутствует синхронизация lifecycle событий

Решение — корректная настройка refetchOnMountOrArgChange и refetchOnFocus.

export const store = configureStore({
  reducer: {
    [api.reducerPath]: api.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      immutableCheck: false,
      serializableCheck: false,
    }).concat(api.middleware),
})

И подключение listeners:

import { setupListeners } from '@reduxjs/toolkit/query'

setupListeners(store.dispatch)

SSR и cache lifecycle RTK Query

RTK Query различает несколько уровней состояния:

  • query cache entries
  • subscription counts
  • mutation cache
  • internal timers invalidation

При SSR важно помнить:

  • на сервере нет подписок компонентов
  • кэш существует только до завершения рендера
  • garbage collection не имеет смысла на сервере

Поэтому часто используют принудительную очистку store после запроса, если он живёт дольше одного рендера.

Next.js App Router (React Server Components)

В App Router подход отличается: серверные компоненты позволяют выполнять запросы напрямую, но RTK Query используется через клиентский слой или через prefetch в server actions.

Типовая схема:

  • сервер создаёт store
  • выполняет dispatch запросов
  • сериализует state
  • передаёт в client boundary
const store = createStore()

await store.dispatch(api.endpoints.getUser.initiate(userId))

const state = store.getState()

Далее state передаётся в клиентский компонент.

Гидрация через custom extractRehydrationInfo

RTK Query поддерживает кастомную логику восстановления через extractRehydrationInfo, особенно в связке с Next.js и redux wrappers.

export const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts',
    }),
  }),
  extractRehydrationInfo(action, { reducerPath }) {
    if (action.type === 'HYDRATE') {
      return action.payload[reducerPath]
    }
  },
})

Это позволяет корректно восстановить кэш RTK Query при серверной гидрации.

Инвалидация данных в SSR контексте

SSR не отменяет необходимость работы с инвалидацией. Однако есть отличие:

  • серверный кэш живёт один запрос
  • клиентский кэш живёт дольше и требует тегов
endpoints: (builder) => ({
  getPosts: builder.query({
    query: () => '/posts',
    providesTags: ['Posts'],
  }),
})

При мутациях на клиенте RTK Query автоматически обновляет связанные запросы.

Оптимизация SSR-загрузки

Для минимизации времени ответа используются:

  • параллельные dispatch запросов
  • предварительное заполнение кэша
  • выборочная загрузка endpoints
await Promise.all([
  store.dispatch(api.endpoints.getUser.initiate(userId)),
  store.dispatch(api.endpoints.getSettings.initiate()),
])

RTK Query объединяет одинаковые запросы, поэтому дублирование не приводит к лишним HTTP вызовам.

Серилизация состояния и ограничения

При передаче state с сервера необходимо учитывать:

  • Redux state должен быть сериализуемым
  • RTK Query хранит metadata, пригодную для JSON
  • нельзя передавать функции или классы

Особенно важно не нарушать serializableCheck, иначе hydration может сломаться.

Поведение кэша при переходах между страницами

В SSR-приложениях переходы между страницами используют уже гидратированный store. RTK Query:

  • использует существующий cache entry
  • повторно не запрашивает данные при совпадении аргументов
  • обновляет данные только при истечении cache lifetime или инвалидации

Работа с authentication в SSR

Частая проблема — передача токена на сервер:

const baseQuery = fetchBaseQuery({
  baseUrl: '/api',
  prepareHeaders: (headers, { getState }) => {
    const token = getState().auth.token
    if (token) {
      headers.set('authorization', `Bearer ${token}`)
    }
    return headers
  },
})

В SSR токен часто берётся из cookies запроса и передаётся в createStore контекстом.

Изоляция запросов и предотвращение утечек данных

Каждый SSR-запрос должен:

  • создавать новый store
  • не использовать singleton api cache
  • не переиспользовать middleware state

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

Поведение polling и subscriptions

RTK Query поддерживает polling и подписки, но в SSR:

  • polling отключён автоматически
  • subscriptions не активны до гидрации
  • listeners работают только на клиенте

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

Типовые ошибки интеграции SSR

На практике чаще всего встречаются:

  • использование глобального store на сервере
  • отсутствие передачи preloadedState
  • повторные запросы из-за неправильной гидрации
  • смешивание client/server fetch логики
  • попытка использовать React hooks на сервере

Каждая из этих ошибок приводит либо к утечкам данных, либо к удвоению запросов.

Согласованность данных между сервером и клиентом

RTK Query в SSR строится вокруг принципа:

  • сервер формирует начальный кэш
  • клиент продолжает его использовать
  • любые расхождения устраняются через refetch или invalidation

Модель позволяет избежать “водопадов” запросов на клиенте и ускоряет первичную отрисовку интерфейса за счёт уже готового состояния.