Жизненный цикл запроса

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

Жизненный цикл является одной из центральных частей архитектуры RTK Query, поскольку именно он определяет:

  • когда выполняется HTTP-запрос;
  • как данные попадают в store;
  • каким образом обновляется UI;
  • когда используется кэш;
  • как запускаются повторные запросы;
  • когда старые данные удаляются из памяти.

RTK Query автоматизирует большую часть этой логики, скрывая сложные внутренние механизмы Redux.


Основные стадии жизненного цикла

Типичный запрос проходит следующие этапы:

  1. Создание подписки на endpoint
  2. Проверка кэша
  3. Отправка запроса
  4. Переход в состояние loading
  5. Получение ответа
  6. Сохранение результата в cache
  7. Обновление состояния подписчиков
  8. Возможная повторная загрузка
  9. Удаление неиспользуемого кэша

Инициализация запроса

Жизненный цикл начинается в момент вызова query hook.

const { data, isLoading } = useGetPostsQuery()

После вызова hook RTK Query:

  • вычисляет cache key;
  • проверяет наличие данных в store;
  • создает подписку на endpoint;
  • определяет необходимость сетевого запроса.

Формирование cache key

Каждый запрос получает уникальный идентификатор.

Например:

useGetPostQuery(5)

RTK Query формирует cache key примерно такого вида:

getPost(5)

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

useGetPostQuery(5)

RTK Query не станет отправлять второй HTTP-запрос, а использует уже существующий кэш.

Это один из важнейших механизмов оптимизации библиотеки.


Проверка кэша

После вычисления cache key RTK Query ищет данные в Redux store.

Если данные:

  • существуют;
  • не устарели;
  • не требуют refetch;

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

Вместо этого данные сразу возвращаются компоненту.


Состояние загрузки

Если данные отсутствуют или требуется обновление, RTK Query переводит запрос в состояние загрузки.

const { isLoading } = useGetPostsQuery()

Во время первой загрузки:

isLoading === true

После успешного завершения:

isLoading === false

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

RTK Query разделяет:

  • первичную загрузку;
  • повторную загрузку.

isLoading

Используется только при первой загрузке, когда данных еще нет.

if (isLoading) {
  return <Spinner />
}

isFetching

Показывает любую активную сетевую операцию, включая refetch.

if (isFetching) {
  console.log('Обновление данных')
}

Даже если данные уже существуют:

{
  data: [...],
  isLoading: false,
  isFetching: true
}

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


Выполнение baseQuery

После перехода в loading RTK Query вызывает baseQuery.

Обычно используется fetchBaseQuery:

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

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api'
  }),
  endpoints: (builder) => ({
    getPosts: builder.query({
      query: () => '/posts'
    })
  })
})

Во время выполнения:

fetch('/api/posts')

Результат преобразуется во внутренний формат RTK Query.


Состояние pending

Во время выполнения запроса endpoint находится в состоянии pending.

В store хранится информация:

{
  status: 'pending'
}

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

  • requestId;
  • время начала запроса;
  • активных подписчиков;
  • параметры query;
  • текущий статус.

Успешное завершение запроса

После получения успешного ответа RTK Query:

  1. сохраняет данные в cache;
  2. обновляет Redux state;
  3. уведомляет подписчиков;
  4. переводит запрос в fulfilled.

Сохранение данных в cache

RTK Query хранит результат запроса внутри slice API.

Упрощенно структура выглядит так:

{
  api: {
    queries: {
      'getPosts(undefined)': {
        status: 'fulfilled',
        data: [...]
      }
    }
  }
}

Кэш становится единым источником данных для всех компонентов.


Обновление компонентов

После обновления store RTK Query инициирует повторный рендер компонентов.

const { data } = useGetPostsQuery()

После получения ответа:

data = [
  { id: 1, title: 'Post 1' }
]

Компонент автоматически получает новые данные без dispatch и reducer.


Состояние error

Если запрос завершился ошибкой:

  • status становится rejected;
  • появляется объект error;
  • данные не обновляются.
const { error } = useGetPostsQuery()

Пример ошибки:

{
  status: 404,
  data: {
    message: 'Not Found'
  }
}

Повторные запросы

RTK Query поддерживает автоматический refetch.

Причины повторной загрузки:

  • изменение аргументов query;
  • повторный mount компонента;
  • восстановление соединения;
  • возврат на вкладку;
  • invalidation tags;
  • ручной refetch.

Изменение query arguments

При изменении аргумента формируется новый cache key.

useGetPostQuery(1)

Затем:

useGetPostQuery(2)

RTK Query считает это двумя независимыми запросами.


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

Можно автоматически выполнять повторный запрос при новом mount.

useGetPostsQuery(undefined, {
  refetchOnMountOrArgChange: true
})

Теперь повторный mount вызовет refetch.


Refetch при восстановлении соединения

RTK Query умеет отслеживать состояние сети.

setupListeners(store.dispatch)

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

refetchOnReconnect: true

Refetch при возврате на вкладку

RTK Query может обновлять данные при возврате пользователя в браузерную вкладку.

refetchOnFocus: true

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

  • чатов;
  • dashboard;
  • мониторингов;
  • торговых систем;
  • административных панелей.

Ручной refetch

Любой query hook предоставляет функцию refetch.

const { refetch } = useGetPostsQuery()

Вызов:

await refetch()

запускает новый сетевой запрос независимо от состояния кэша.


Жизненный цикл mutation

Mutation имеют собственный жизненный цикл.

Пример:

const [createPost, result] = useCreatePostMutation()

После вызова:

await createPost({
  title: 'New Post'
})

mutation проходит стадии:

  1. pending
  2. fulfilled/rejected
  3. invalidation
  4. refetch связанных query

Invalidation и обновление cache

Mutation часто запускают обновление query.

tagTypes: ['Posts']

Query:

getPosts: builder.query({
  query: () => '/posts',
  providesTags: ['Posts']
})

Mutation:

createPost: builder.mutation({
  query: (body) => ({
    url: '/posts',
    method: 'POST',
    body
  }),
  invalidatesTags: ['Posts']
})

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

  1. помечает tag как invalid;
  2. находит связанные query;
  3. выполняет автоматический refetch.

Время жизни кэша

RTK Query удаляет неиспользуемые данные автоматически.

По умолчанию cache хранится:

60 секунд

после исчезновения последнего подписчика.


keepUnusedDataFor

Время хранения можно изменить.

getPosts: builder.query({
  query: () => '/posts',
  keepUnusedDataFor: 300
})

Теперь кэш хранится:

300 секунд

Подписчики и reference counting

RTK Query использует механизм счетчика подписчиков.

Если два компонента используют:

useGetPostsQuery()

то создается:

1 cache entry
2 subscribers

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

1 subscriber

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

0 subscribers

После этого запускается таймер удаления cache entry.


Удаление кэша

После истечения keepUnusedDataFor RTK Query:

  • удаляет данные;
  • очищает metadata;
  • освобождает память.

Следующий запрос снова выполнит HTTP-запрос.


Lifecycle callbacks

RTK Query предоставляет lifecycle callbacks.

Основные:

  • onQueryStarted
  • onCacheEntryAdded

onQueryStarted

Вызывается сразу после старта запроса.

getPost: builder.query({
  query: (id) => `/posts/${id}`,

  async onQueryStarted(arg, api) {
    console.log('Запрос начался')
  }
})

Используется для:

  • optimistic updates;
  • логирования;
  • аналитики;
  • ручного обновления cache;
  • побочных эффектов.

Доступные параметры onQueryStarted

В callback доступны:

async onQueryStarted(arg, {
  dispatch,
  getState,
  queryFulfilled,
  requestId,
  extra,
  getCacheEntry
})

queryFulfilled

queryFulfilled содержит Promise результата запроса.

async onQueryStarted(arg, { queryFulfilled }) {
  try {
    const result = await queryFulfilled

    console.log(result.data)
  } catch (error) {
    console.log(error)
  }
}

Optimistic updates

RTK Query поддерживает optimistic update через updateQueryData.

async onQueryStarted(post, {
  dispatch,
  queryFulfilled
}) {
  const patchResult = dispatch(
    api.util.updateQueryData(
      'getPosts',
      undefined,
      (draft) => {
        draft.push(post)
      }
    )
  )

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

Механизм работает так:

  1. cache обновляется мгновенно;
  2. UI сразу показывает изменения;
  3. запрос отправляется на сервер;
  4. при ошибке выполняется rollback.

onCacheEntryAdded

Callback связан с жизненным циклом cache entry.

onCacheEntryAdded: async (
  arg,
  {
    cacheDataLoaded,
    cacheEntryRemoved
  }
) => {

}

cacheDataLoaded

Promise завершается после появления данных в cache.

await cacheDataLoaded

cacheEntryRemoved

Promise завершается после удаления cache entry.

await cacheEntryRemoved

Это позволяет корректно очищать ресурсы.


Работа с WebSocket

onCacheEntryAdded особенно полезен для WebSocket.

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

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

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

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

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

    await cacheEntryRemoved

    socket.close()
  }
})

Жизненный цикл:

  1. создается cache entry;
  2. открывается WebSocket;
  3. данные обновляют cache;
  4. исчезают подписчики;
  5. cache удаляется;
  6. WebSocket закрывается.

Синхронизация жизненного цикла и Redux

RTK Query полностью интегрирован в Redux lifecycle.

Каждый запрос генерирует actions:

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

Эти actions проходят через:

  • middleware;
  • reducers;
  • devtools;
  • listeners.

Request deduplication

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

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

useGetPostsQuery()

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

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

Polling

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

useGetPostsQuery(undefined, {
  pollingInterval: 5000
})

Теперь запрос выполняется каждые:

5 секунд

Жизненный цикл становится циклическим:

fetch → cache update → wait → refetch

Skip и отложенный запуск

Жизненный цикл можно остановить.

useGetPostQuery(id, {
  skip: !id
})

Пока условие истинно:

  • запрос не запускается;
  • cache entry не создается;
  • lifecycle не начинается.

Lazy queries

RTK Query поддерживает ручной запуск query.

const [trigger, result] = useLazyGetPostsQuery()

Запуск:

await trigger()

До вызова trigger:

  • запрос отсутствует;
  • lifecycle не активен;
  • cache entry не создается.

Abort запросов

RTK Query умеет отменять запросы.

Например:

  • при размонтировании;
  • при новом запросе;
  • при ручной отмене.

Внутри используется AbortController.


Состояние aborted

После отмены запрос получает специальное состояние.

Это предотвращает:

  • race conditions;
  • запись устаревших данных;
  • конфликты асинхронных запросов.

Race conditions

RTK Query автоматически решает проблему гонок запросов.

Например:

Запрос A → 3 секунды
Запрос B → 1 секунда

Если B завершится раньше A, RTK Query корректно обработает порядок обновлений.


Streaming и долгоживущие подключения

Жизненный цикл RTK Query подходит не только для HTTP.

Через onCacheEntryAdded можно поддерживать:

  • WebSocket;
  • SSE;
  • streaming API;
  • realtime subscriptions.

Кэш становится централизованным realtime-хранилищем.


Внутреннее устройство lifecycle

RTK Query хранит несколько сущностей:

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

Каждая часть участвует в жизненном цикле:

  • queries — состояние query;
  • mutations — состояние mutation;
  • subscriptions — активные подписчики;
  • provided — система tags;
  • config — настройки API.

Полный сценарий жизненного цикла

Типичный сценарий выглядит так:

Component mount
↓
Hook execution
↓
Cache lookup
↓
HTTP request
↓
pending
↓
fulfilled
↓
cache update
↓
UI rerender
↓
mutation
↓
tag invalidation
↓
refetch
↓
unused cache
↓
cache cleanup

Именно этот автоматизированный pipeline делает RTK Query полноценной системой управления серверным состоянием, а не просто инструментом для выполнения fetch-запросов.