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

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

Восстановление состояния применяется в нескольких сценариях:

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

TanStack Query позволяет сериализовать состояние QueryClient, сохранить его во внешнем хранилище и позже восстановить.


Что включает состояние QueryClient

Состояние TanStack Query состоит из:

  • query cache;
  • mutation cache;
  • метаданных запросов;
  • timestamps;
  • статусов загрузки;
  • результатов запросов;
  • retry state;
  • observer state.

Пример внутреннего состояния:

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

Базовая сериализация состояния

TanStack Query предоставляет функции:

  • dehydrate
  • hydrate

Экспорт состояния

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

const dehydratedState = dehydrate(queryClient)

dehydrate извлекает сериализуемое состояние клиента.


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

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

hydrate(queryClient, dehydratedState)

После вызова hydrate кеш восстанавливается.


Полный пример сохранения и восстановления

Сохранение

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

const queryClient = new QueryClient()

function saveCache() {
  const state = dehydrate(queryClient)

  localStorage.setItem(
    'APP_CACHE',
    JSON.stringify(state)
  )
}

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

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

const queryClient = new QueryClient()

const cache = localStorage.getItem('APP_CACHE')

if (cache) {
  hydrate(
    queryClient,
    JSON.parse(cache)
  )
}

Persist Query Client

Для автоматизации восстановления существует пакет:

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

Дополнительно используются persister-адаптеры.


PersistQueryClientProvider

Библиотека содержит специальный provider:

import {
  PersistQueryClientProvider
} from '@tanstack/react-query-persist-client'

Он автоматически:

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

Создание persister

LocalStorage persister

npm install @tanstack/query-sync-storage-persister

Настройка persister

import { createSyncStoragePersister }
from '@tanstack/query-sync-storage-persister'

const persister = createSyncStoragePersister({
  storage: window.localStorage
})

Полная настройка PersistQueryClientProvider

import React from 'react'

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

import {
  PersistQueryClientProvider
} from '@tanstack/react-query-persist-client'

import {
  createSyncStoragePersister
} from '@tanstack/query-sync-storage-persister'

const queryClient = new QueryClient()

const persister = createSyncStoragePersister({
  storage: window.localStorage
})

export function App() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{
        persister
      }}
    >
      <Main />
    </PersistQueryClientProvider>
  )
}

Как работает автоматическая персистентность

Алгоритм работы:

  1. QueryClient изменяет состояние.
  2. Persist layer отслеживает обновления.
  3. Состояние сериализуется.
  4. Данные сохраняются в storage.
  5. После перезапуска приложение восстанавливает кеш.
  6. QueryClient получает готовые данные.

Типы persister

TanStack Query поддерживает разные варианты хранения.

Sync Storage Persister

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

  • localStorage;
  • sessionStorage.
createSyncStoragePersister({
  storage: window.localStorage
})

Async Storage Persister

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

  • React Native;
  • IndexedDB;
  • асинхронных API хранения.
npm install @tanstack/query-async-storage-persister

Async persister

import { createAsyncStoragePersister }
from '@tanstack/query-async-storage-persister'

const persister = createAsyncStoragePersister({
  storage: AsyncStorage
})

IndexedDB persister

Большие кеши лучше хранить в IndexedDB.

Пример через localforage:

npm install localforage

import localforage from 'localforage'

const persister = createAsyncStoragePersister({
  storage: localforage
})

Ограничение времени жизни кеша

Persist layer поддерживает maxAge.

persistOptions={{
  persister,
  maxAge: 1000 * 60 * 60 * 24
}}

Пример:

24 часа = 86400000 ms

Если кеш старее maxAge, восстановление не произойдёт.


Buster version

Для инвалидирования старых кешей используется buster.

persistOptions={{
  persister,
  buster: 'v2'
}}

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

Это важно при:

  • изменении API;
  • изменении структуры данных;
  • миграциях приложения.

Фильтрация сохраняемых запросов

Не все запросы должны попадать в persistent cache.

Используется shouldDehydrateQuery.

dehydrate(queryClient, {
  shouldDehydrateQuery: (query) => {
    return query.state.status === 'success'
  }
})

Исключение чувствительных данных

Часто запрещено сохранять:

  • токены;
  • приватные данные;
  • банковскую информацию;
  • временные session-данные.

Пример:

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

Управление размером кеша

localStorage имеет ограничения.

Обычно:

5–10 MB

Большие кеши вызывают:

  • переполнение;
  • ошибки сериализации;
  • падение производительности.

Стратегии уменьшения размера

Сохранять только успешные запросы

shouldDehydrateQuery: (query) =>
  query.state.status === 'success'

Исключать временные данные

query.queryKey[0] !== 'realtime'

Уменьшать cacheTime

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      gcTime: 1000 * 60 * 5
    }
  }
})

Сжатие состояния

Иногда применяется compression.

Например:

npm install lz-string

Сжатие перед сохранением

import LZString from 'lz-string'

const state = dehydrate(queryClient)

const compressed =
  LZString.compress(JSON.stringify(state))

localStorage.setItem('CACHE', compressed)

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

const compressed =
  localStorage.getItem('CACHE')

const state = JSON.parse(
  LZString.decompress(compressed)
)

hydrate(queryClient, state)

Persist и staleTime

После восстановления данные могут:

  • считаться свежими;
  • считаться устаревшими.

Это определяется staleTime.

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

Если staleTime не истёк:

  • повторный запрос не выполняется;
  • UI использует восстановленный кеш.

Persist и refetchOnMount

Даже восстановленный кеш может автоматически обновляться.

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

Persist и refetchOnWindowFocus

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

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

Данные могут быть обновлены повторно.


Восстановление mutation cache

TanStack Query умеет сохранять mutation state.

Это важно для:

  • offline-first приложений;
  • повторной отправки запросов;
  • фоновой синхронизации.

Persisted mutations

Пример:

const mutation = useMutation({
  mutationFn: createPost
})

После восстановления TanStack Query может продолжить pending mutations.


Offline mutations

В оффлайн-режиме mutation может ждать восстановления сети.

networkMode: 'offlineFirst'

Пример offline mutation

const mutation = useMutation({
  mutationFn: sendMessage,
  networkMode: 'offlineFirst'
})

PersistQueryClientProvider и hydration

Во время восстановления provider блокирует запросы до завершения hydration.

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

  • двойные запросы;
  • race conditions;
  • потерю кеша.

useIsRestoring

TanStack Query предоставляет специальный хук:

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

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

const isRestoring = useIsRestoring()

if (isRestoring) {
  return <Loader />
}

SSR и восстановление состояния

В SSR восстановление играет ключевую роль.

Схема:

  1. Сервер загружает данные.
  2. Выполняется dehydrate.
  3. Состояние передаётся клиенту.
  4. Клиент вызывает hydrate.
  5. Повторные запросы не выполняются.

SSR dehydration

const queryClient = new QueryClient()

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

const dehydratedState =
  dehydrate(queryClient)

Передача состояния клиенту

return {
  props: {
    dehydratedState
  }
}

Клиентская гидратация

hydrate(queryClient, dehydratedState)

HydrationBoundary

В современных версиях применяется:

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

Пример

<HydrationBoundary state={dehydratedState}>
  <PostsPage />
</HydrationBoundary>

Persist и React Native

В React Native обычно используется:

AsyncStorage

Настройка

import AsyncStorage
from '@react-native-async-storage/async-storage'

const persister =
  createAsyncStoragePersister({
    storage: AsyncStorage
  })

Persist и PWA

PWA-приложения особенно выигрывают от persistent cache.

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

  • мгновенный старт;
  • оффлайн-доступ;
  • минимизация network traffic;
  • улучшение UX.

Проблемы сериализации

Не все данные сериализуются корректно.

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

  • Date;
  • Map;
  • Set;
  • class instances;
  • functions;
  • circular references.

Пример проблемы с Date

{
  createdAt: new Date()
}

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

typeof createdAt === 'string'

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

Рекомендуется хранить:

  • plain objects;
  • JSON-compatible структуры;
  • сериализуемые данные.

Обработка ошибок восстановления

Иногда кеш повреждается.

Пример:

try {
  hydrate(queryClient, state)
} catch (error) {
  localStorage.removeItem('CACHE')
}

Retry persistence

Pending mutations могут повторяться автоматически.

retry: 3

Persist и gcTime

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

queries: {
  gcTime: 1000 * 60 * 30
}

Даже восстановленные данные могут быть удалены garbage collector.


Синхронизация между вкладками

Persist cache можно комбинировать с broadcast sync.

Например:

npm install @tanstack/query-broadcast-client-experimental

BroadcastQueryClient

import {
  broadcastQueryClient
} from '@tanstack/query-broadcast-client-experimental'

broadcastQueryClient({
  queryClient,
  broadcastChannel: 'app-cache'
})

Что происходит при синхронизации

Изменения в одной вкладке:

  • обновляют кеш;
  • рассылаются через BroadcastChannel;
  • синхронизируются между окнами.

Архитектурные рекомендации

Хорошо подходят для persistence

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

Плохо подходят

  • realtime data;
  • volatile cache;
  • streaming data;
  • временные состояния;
  • высокочастотные обновления.

Когда persistence особенно эффективен

Большие API

REST
GraphQL
gRPC

Медленные сети

3G
mobile
satellite internet

Enterprise-приложения

Где:

  • дорого выполнять повторные запросы;
  • важен offline UX;
  • требуется быстрое восстановление интерфейса.

Распространённые ошибки

Сохранение слишком большого кеша

Вызывает:

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

Persist чувствительных данных

Ошибка безопасности:

['auth']
['token']
['payment']

Отсутствие buster

После обновления приложения возможны:

  • несовместимые структуры;
  • повреждённый UI;
  • ошибки hydration.

Слишком длинный staleTime

Пользователь получает устаревшие данные.


Persist realtime-запросов

Например:

  • WebSocket cache;
  • live notifications;
  • streaming state.

Это приводит к восстановлению неактуального состояния.