Гидратация состояния на клиенте

Гидратация состояния — это процесс восстановления данных приложения на клиенте после первоначальной загрузки. В контексте браузерных приложений это означает извлечение ранее сохранённого состояния (например, из IndexedDB) и его внедрение в текущую сессию, чтобы обеспечить непрерывность пользовательского опыта.

При использовании библиотеки idb-keyval гидратация реализуется за счёт простого API для работы с IndexedDB, позволяющего сохранять и извлекать данные асинхронно, без необходимости напрямую взаимодействовать с низкоуровневым API браузера.


Роль idb-keyval в гидратации

Библиотека предоставляет минималистичный интерфейс:

  • get(key) — получение значения
  • set(key, value) — сохранение значения
  • del(key) — удаление
  • clear() — очистка хранилища

Эти методы позволяют организовать механизм восстановления состояния без сложной инфраструктуры.

Ключевая идея — при загрузке приложения извлечь данные из IndexedDB и синхронизировать их с состоянием UI или state-менеджера.


Базовый сценарий гидратации

Сохранение состояния

import { set } from 'idb-keyval';

async function saveState(state) {
  await set('app-state', state);
}

Состояние может включать:

  • настройки пользователя
  • кэшированные данные API
  • промежуточные формы
  • состояние UI

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

import { get } from 'idb-keyval';

async function loadState() {
  const state = await get('app-state');
  return state || {};
}

Интеграция с инициализацией приложения

Гидратация должна происходить до рендера UI, чтобы избежать “мигания” интерфейса и несогласованности данных.

Пример:

async function bootstrapApp() {
  const persistedState = await loadState();

  const app = createApp({
    initialState: persistedState
  });

  app.mount('#app');
}

bootstrapApp();

Частичная гидратация

Не всегда требуется восстанавливать всё состояние. Более эффективный подход — выборочная гидратация.

const keys = ['user', 'settings', 'cart'];

async function hydratePartial() {
  const result = {};

  for (const key of keys) {
    result[key] = await get(key);
  }

  return result;
}

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

  • уменьшение времени загрузки
  • снижение нагрузки на IndexedDB
  • контроль над приоритетами данных

Ленивое восстановление (Lazy Hydration)

Часть данных может быть загружена позже, по мере необходимости.

async function getUserData() {
  let user = await get('user');

  if (!user) {
    user = await fetch('/api/user').then(r => r.json());
    await set('user', user);
  }

  return user;
}

Такой подход:

  • ускоряет начальную загрузку
  • снижает блокировку основного потока

Синхронизация состояния

Гидратация — это не одноразовое действие. Важно поддерживать синхронизацию между:

  • памятью приложения
  • IndexedDB

Пример:

function subscribeToStore(store) {
  store.subscribe(async (state) => {
    await set('app-state', state);
  });
}

Работа с версиями состояния

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

const CURRENT_VERSION = 2;

async function loadState() {
  const data = await get('app-state');

  if (!data) return {};

  if (data.version !== CURRENT_VERSION) {
    return migrate(data);
  }

  return data;
}

Пример миграции:

function migrate(oldState) {
  if (oldState.version === 1) {
    return {
      ...oldState,
      newField: null,
      version: 2
    };
  }

  return oldState;
}

Обработка ошибок

IndexedDB — асинхронное API, и ошибки возможны:

  • отказ доступа
  • повреждение базы
  • переполнение
async function safeLoad() {
  try {
    return await get('app-state');
  } catch (e) {
    console.error('Ошибка загрузки состояния', e);
    return {};
  }
}

Кэширование API-ответов

Гидратация часто используется для повторного использования данных API:

async function fetchWithCache(key, url) {
  const cached = await get(key);

  if (cached) return cached;

  const response = await fetch(url);
  const data = await response.json();

  await set(key, data);

  return data;
}

Очистка устаревших данных

Без контроля IndexedDB может разрастаться.

async function clearOldCache() {
  const timestamp = await get('cache-time');

  if (Date.now() - timestamp > 86400000) {
    await clear();
  }
}

Использование кастомных хранилищ

idb-keyval позволяет создавать отдельные store:

import { createStore, set, get } from 'idb-keyval';

const customStore = createStore('my-db', 'my-store');

await set('key', 'value', customStore);
const value = await get('key', customStore);

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

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

Интеграция с state-менеджерами

Redux

store.subscribe(async () => {
  const state = store.getState();
  await set('redux-state', state);
});

Гидратация:

const preloadedState = await get('redux-state');
const store = createStore(reducer, preloadedState);

Zustand

const useStore = create((set) => ({
  data: null,
  hydrate: async () => {
    const data = await get('data');
    set({ data });
  }
}));

Производительность и ограничения

  • IndexedDB работает асинхронно, но может блокировать основной поток при больших объёмах
  • сериализация JSON влияет на скорость
  • частые записи могут замедлять приложение

Рекомендации:

  • избегать сохранения больших объектов целиком
  • использовать дебаунс при записи
  • разделять состояние на части

Дебаунс записи состояния

function debounce(fn, delay) {
  let timeout;
  return (...args) => {
    clearTimeout(timeout);
    timeout = setTimeout(() => fn(...args), delay);
  };
}

const save = debounce((state) => {
  set('app-state', state);
}, 300);

Безопасность данных

IndexedDB доступна из JavaScript, поэтому:

  • не хранить чувствительные данные (токены, пароли)
  • использовать шифрование при необходимости

Асинхронная инициализация UI

Гидратация может повлиять на рендеринг:

let isReady = false;

async function init() {
  const state = await loadState();
  render(state);
  isReady = true;
}

В UI:

if (!isReady) {
  return <Loading />;
}

Паттерн “rehydrate on demand”

Иногда состояние восстанавливается только при обращении:

class Store {
  constructor() {
    this.cache = null;
  }

  async getState() {
    if (!this.cache) {
      this.cache = await get('state');
    }
    return this.cache;
  }
}

Тестирование гидратации

Для тестов удобно мокать idb-keyval:

jest.mock('idb-keyval', () => ({
  get: jest.fn(),
  set: jest.fn()
}));

Типизация (TypeScript)

type AppState = {
  user: string;
  theme: string;
};

async function loadState(): Promise<AppState> {
  const state = await get('app-state');
  return state as AppState;
}

Архитектурные подходы

  1. Centralized hydration

    • единая точка загрузки состояния
  2. Modular hydration

    • каждый модуль сам управляет своими данными
  3. Hybrid

    • базовое состояние загружается сразу, остальное — лениво

Ограничения idb-keyval

  • отсутствие транзакций на уровне нескольких операций
  • минимальный контроль над IndexedDB
  • нет встроенной схемы данных

Однако для гидратации состояния это компенсируется:

  • простотой
  • малым размером
  • достаточной функциональностью

Практический паттерн

async function initApp() {
  const state = await loadState();

  const store = createStore(state);

  store.subscribe(
    debounce((newState) => {
      set('app-state', newState);
    }, 200)
  );

  render(store);
}

Такой подход обеспечивает:

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