Архитектура RTK Query

RTK Query представляет собой надстройку над Redux Toolkit, предназначенную для организации слоя работы с серверными данными. Архитектура библиотеки строится вокруг декларативного описания API, автоматического кэширования, генерации хуков и централизованного управления состоянием запросов.

В основе архитектуры лежат несколько ключевых компонентов:

  • API Slice
  • Base Query Layer
  • Endpoints
  • Cache System
  • Subscription System
  • Middleware
  • Generated Hooks
  • Tag-based Invalidation

Главная особенность архитектуры RTK Query заключается в том, что серверное состояние рассматривается как отдельный слой приложения, независимый от UI и локального client-side state.


Разделение клиентского и серверного состояния

Классический Redux исторически использовался для хранения любого состояния приложения:

  • формы;
  • модальные окна;
  • фильтры;
  • данные API;
  • состояние загрузки;
  • ошибки.

RTK Query меняет подход к архитектуре и отделяет:

Client State

Локальное состояние интерфейса:

{
  theme: 'dark',
  modalOpen: true,
  sidebarCollapsed: false
}

Server State

Данные, приходящие с сервера:

{
  users: [...],
  posts: [...],
  comments: [...]
}

Server state обладает особенностями:

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

RTK Query полностью берет управление этим слоем на себя.


Центральный элемент архитектуры — API Slice

Архитура RTK Query строится вокруг createApi.

Именно API Slice становится единым контейнером для:

  • endpoint-ов;
  • кэша;
  • middleware;
  • генерации хуков;
  • логики инвалидации;
  • подписок.

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

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const api = createApi({
  reducerPath: 'api',

  baseQuery: fetchBaseQuery({
    baseUrl: '/api'
  }),

  endpoints: (builder) => ({
    getUsers: builder.query({
      query: () => '/users'
    })
  })
})

Архитектурная роль reducerPath

reducerPath определяет namespace внутри Redux Store.

Пример:

reducerPath: 'api'

В store появится:

{
  api: {
    queries: {},
    mutations: {},
    subscriptions: {},
    provided: {}
  }
}

Это изолирует внутреннюю инфраструктуру RTK Query от остального Redux-состояния.


Внутреннее устройство API Slice

API Slice содержит несколько внутренних подсистем.

Queries

Хранение query-кэша:

state.api.queries

Пример:

{
  'getUsers(undefined)': {
    status: 'fulfilled',
    data: [...],
    fulfilledTimeStamp: 123456
  }
}

Mutations

Хранение состояния mutation-запросов:

state.api.mutations

Subscriptions

Система активных подписок компонентов:

state.api.subscriptions

RTK Query отслеживает:

  • какие компоненты используют query;
  • сколько подписчиков существует;
  • когда удалять кэш.

Provided Tags

Система связей между query и mutation:

state.api.provided

Используется для автоматической инвалидации.


Base Query Layer

Архитектура RTK Query отделяет транспортный слой от endpoint-ов.

Эта задача решается через baseQuery.


fetchBaseQuery

Стандартная реализация поверх Fetch API:

baseQuery: fetchBaseQuery({
  baseUrl: '/api'
})

Она отвечает за:

  • HTTP-запросы;
  • сериализацию;
  • headers;
  • body;
  • JSON parsing;
  • ошибки.

Архитектурное преимущество baseQuery

Endpoints не знают:

  • как выполняется HTTP-запрос;
  • какой клиент используется;
  • как обрабатываются токены;
  • как обновляется авторизация.

Endpoints описывают только:

query: () => '/users'

Весь транспорт инкапсулирован.


Кастомный Base Query

Архитектура позволяет заменить транспортный слой полностью.

Пример с Axios:

const axiosBaseQuery =
  ({ baseUrl }) =>
  async ({ url, method, data }) => {
    try {
      const result = await axios({
        url: baseUrl + url,
        method,
        data
      })

      return { data: result.data }
    } catch (error) {
      return {
        error: {
          status: error.response?.status,
          data: error.response?.data
        }
      }
    }
  }

Endpoint Architecture

Endpoints являются декларативным описанием API.

RTK Query разделяет endpoints на два типа:

  • Query
  • Mutation

Query Endpoints

Query предназначен для чтения данных.

getUsers: builder.query({
  query: () => '/users'
})

Архитурно query включает:

  • cache key;
  • lifecycle;
  • subscriptions;
  • polling;
  • refetching;
  • memoization.

Mutation Endpoints

Mutation предназначен для изменения данных.

createUser: builder.mutation({
  query: (body) => ({
    url: '/users',
    method: 'POST',
    body
  })
})

Mutation архитектурно отличается:

  • не кэшируется как query;
  • имеет временное состояние;
  • используется для invalidation;
  • запускает side effects.

Builder Pattern

RTK Query использует builder API.

endpoints: (builder) => ({
  getUsers: builder.query(...),
  createUser: builder.mutation(...)
})

Builder обеспечивает:

  • типизацию;
  • декларативность;
  • расширяемость;
  • lazy evaluation.

Архитектура кэширования

Кэш — центральная часть RTK Query.


Cache Key System

Каждый query получает уникальный cache key.

Пример:

useGetUserQuery(5)

Создает ключ:

'getUser(5)'

Если другой компонент вызывает тот же query:

useGetUserQuery(5)

RTK Query не выполняет новый запрос.

Используется существующий cache entry.


Deduplication Architecture

RTK Query автоматически предотвращает дублирующие запросы.

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

useGetUsersQuery()

в нескольких компонентах:

<UserList />
<UserSidebar />
<UserStats />

будет выполнен только один HTTP-запрос.

Все компоненты подпишутся на общий cache entry.


Subscription-based Cache

Архитектура кэша построена на подписках.

Когда компонент монтируется:

useGetUsersQuery()

создается subscription.

Когда компонент размонтируется — subscription удаляется.


Cache Lifetime

После удаления последнего подписчика RTK Query не очищает кэш мгновенно.

Используется таймер:

keepUnusedDataFor: 60

Данные сохраняются:

60 секунд

Это уменьшает повторные запросы.


Garbage Collection

RTK Query реализует автоматическую очистку кэша.

Процесс:

  1. Подписчики исчезают
  2. Запускается таймер
  3. Cache entry удаляется

Это предотвращает утечки памяти.


Архитектура инвалидации

Одно из главных преимуществ RTK Query — tag-based invalidation.


Tags

Query может предоставлять tags:

getUsers: builder.query({
  query: () => '/users',

  providesTags: ['Users']
})

Mutation может инвалидировать их:

createUser: builder.mutation({
  query: (body) => ({
    url: '/users',
    method: 'POST',
    body
  }),

  invalidatesTags: ['Users']
})

Механизм работы invalidation

После успешной mutation:

invalidatesTags: ['Users']

RTK Query:

  1. Находит query с tag Users
  2. Помечает кэш устаревшим
  3. Выполняет refetch
  4. Обновляет все компоненты

Entity-based Invalidation

Архитектура поддерживает granular invalidation.

providesTags: (result, error, id) => [
  { type: 'Users', id }
]

Mutation:

invalidatesTags: (result, error, id) => [
  { type: 'Users', id }
]

Это позволяет обновлять только конкретный entity cache.


Normalized Cache vs Document Cache

RTK Query использует document cache architecture.

Это означает:

getUsers()
getUser(id)

хранятся независимо.

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


Причины document cache architecture

Полная normalization architecture:

  • сложнее;
  • требует entity graph;
  • увеличивает сложность invalidation;
  • усложняет lifecycle.

RTK Query делает ставку на:

  • простоту;
  • predictable cache;
  • refetch-based consistency.

Архитектура lifecycle

Каждый query проходит lifecycle.


Query Lifecycle

Этапы:

uninitialized
pending
fulfilled
rejected

Mutation Lifecycle

Mutation имеет похожий lifecycle:

uninitialized
pending
fulfilled
rejected

Internal Request State

RTK Query хранит:

{
  isLoading,
  isFetching,
  isSuccess,
  isError,
  error,
  data
}

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

isLoading

Первый запрос без данных.

isFetching

Повторный запрос при наличии cache data.

Это архитектурно важно для UX.


Refetch Architecture

RTK Query поддерживает несколько стратегий обновления.


Refetch on Mount

refetchOnMountOrArgChange: true

Refetch on Focus

refetchOnFocus: true

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


Refetch on Reconnect

refetchOnReconnect: true

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


Polling Architecture

RTK Query поддерживает polling.

useGetUsersQuery(undefined, {
  pollingInterval: 5000
})

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


Middleware Architecture

RTK Query создает собственный middleware.

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

middleware: (getDefaultMiddleware) =>
  getDefaultMiddleware().concat(api.middleware)

Middleware отвечает за:

  • запросы;
  • polling;
  • refetch;
  • subscriptions;
  • invalidation;
  • garbage collection;
  • async lifecycle.

Почему middleware критически важен

Без middleware RTK Query не сможет:

  • запускать HTTP-запросы;
  • отслеживать подписки;
  • обновлять cache;
  • выполнять polling.

Middleware — центральный runtime engine библиотеки.


Store Integration Architecture

Интеграция выполняется через reducer и middleware.

export const store = configureStore({
  reducer: {
    [api.reducerPath]: api.reducer
  },

  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(api.middleware)
})

Generated Hooks Architecture

RTK Query автоматически генерирует hooks.

Из:

getUsers: builder.query(...)

получается:

useGetUsersQuery()

Почему generated hooks важны

Они скрывают:

  • dispatch;
  • selectors;
  • subscriptions;
  • cache management;
  • lifecycle.

UI получает декларативный API:

const { data } = useGetUsersQuery()

Lazy Queries

RTK Query поддерживает lazy architecture.

const [trigger, result] = useLazyGetUsersQuery()

Запрос выполняется вручную:

trigger()

Streaming Architecture

RTK Query поддерживает потоковое обновление через lifecycle API.


onCacheEntryAdded

Позволяет подключать WebSocket:

getMessages: builder.query({
  query: () => '/messages',

  async onCacheEntryAdded(
    arg,
    { updateCachedData, cacheDataLoaded }
  ) {
    await cacheDataLoaded

    const socket = new WebSocket('ws://localhost')

    socket.onmess age = (event) => {
      const message = JSON.parse(event.data)

      updateCachedData((draft) => {
        draft.push(message)
      })
    }
  }
})

Архитектура optimistic updates

RTK Query поддерживает optimistic UI.


onQueryStarted

Пример:

updateUser: builder.mutation({
  query: ({ id, ...patch }) => ({
    url: `/users/${id}`,
    method: 'PATCH',
    body: patch
  }),

  async onQueryStarted(
    { id, ...patch },
    { dispatch, queryFulfilled }
  ) {
    const patchResult = dispatch(
      api.util.updateQueryData(
        'getUser',
        id,
        (draft) => {
          Object.assign(draft, patch)
        }
      )
    )

    try {
      await queryFulfilled
    } catch {
      patchResult.undo()
    }
  }
})

Internal Action Architecture

RTK Query генерирует внутренние Redux actions.

Примеры:

api/executeQuery/pending
api/executeQuery/fulfilled
api/executeQuery/rejected

Архитектура сериализации аргументов

Аргументы query сериализуются:

useGetUserQuery({
  id: 5
})

RTK Query создает deterministic cache key.

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


Selectors Architecture

RTK Query генерирует selectors.

Пример:

api.endpoints.getUsers.select()

Можно получать cache data напрямую из store.


Code Splitting Architecture

RTK Query поддерживает динамическое расширение API.


injectEndpoints

Пример:

const extendedApi = api.injectEndpoints({
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts'
    })
  })
})

Преимущества code splitting

Позволяет:

  • загружать endpoints лениво;
  • уменьшать bundle size;
  • разделять domain modules;
  • масштабировать enterprise-приложения.

Domain-driven Architecture

Крупные приложения обычно делят API по доменам.

Пример:

src/
  services/
    auth/
    users/
    posts/
    comments/

Архитектурный подход Feature-based Structure

Часто используется структура:

features/
  users/
    api/
    components/
    hooks/

  posts/
    api/
    components/

Централизация API Layer

RTK Query способствует созданию единого data layer.

Вместо:

fetch()
axios()
custom hooks
useEffect

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


SSR Architecture

RTK Query поддерживает server-side rendering.

Особенно важно для:

  • Next.js;
  • Remix;
  • SSR React.

Hydration Architecture

Server cache может передаваться клиенту:

extractRehydrationInfo

Это уменьшает повторные запросы после hydration.


Error Handling Architecture

Ошибки являются частью query state.

const {
  error,
  isError
} = useGetUsersQuery()

Unified Error Model

RTK Query унифицирует ошибки:

{
  status,
  data
}

Это упрощает глобальную обработку.


Retry Architecture

RTK Query поддерживает retry wrapper.

Пример:

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

const staggeredBaseQuery = retry(
  fetchBaseQuery({ baseUrl: '/' }),
  {
    maxRetries: 5
  }
)

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

RTK Query оптимизирован для минимизации re-render.


Memoized Cache References

Если данные не изменились:

data === previousData

ссылка сохраняется.

Это уменьшает количество рендеров.


Selective Subscription

Компоненты подписываются только на нужные query.

Изменение одного cache entry не обновляет весь store.


selectFromResult

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

const { user } = useGetUsersQuery(undefined, {
  selectFromResult: ({ data }) => ({
    user: data?.find((u) => u.id === 5)
  })
})

Архитектурные преимущества RTK Query

Централизация server state

Вся серверная логика находится в одном слое.


Автоматизация инфраструктуры

RTK Query автоматически реализует:

  • cache;
  • deduplication;
  • refetch;
  • invalidation;
  • subscriptions;
  • polling.

Предсказуемость

Архитектура декларативна:

query
mutation
tags

Масштабируемость

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

  • SPA;
  • enterprise systems;
  • SSR applications;
  • modular architecture.

Ограничения архитектуры RTK Query

Отсутствие полной normalization

RTK Query не строит entity graph автоматически.


Redux dependency

RTK Query тесно связан с Redux Toolkit.


Сложность lifecycle API

onQueryStarted onCacheEntryAdded

могут существенно усложнять архитектуру.


Избыточность для маленьких приложений

Для простого CRUD иногда достаточно:

fetch + useEffect

Однако в средних и крупных системах RTK Query значительно уменьшает инфраструктурный код и упрощает управление серверным состоянием.