Инициализация хранилища до рендера компонентов

Инициализация хранилища на основе IndexedDB до момента рендера компонентов позволяет избежать целого ряда проблем:

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

Библиотека idb-keyval предоставляет простой API поверх IndexedDB, но сама по себе не решает вопроса тайминга инициализации. Эта задача ложится на архитектуру приложения.


Базовые принципы инициализации

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

Основные шаги:

  1. Создать или открыть хранилище.
  2. Извлечь данные (или подготовить значения по умолчанию).
  3. Дождаться завершения асинхронных операций.
  4. Только после этого инициировать рендер приложения.

Создание кастомного хранилища

По умолчанию idb-keyval использует базу данных keyval-store. Для контроля над именами и версиями создаётся собственный store:

import { Store } from 'idb-keyval';

export const appStore = new Store('app-db', 'app-store');

Параметры:

  • app-db — имя базы данных;
  • app-store — имя object store внутри IndexedDB.

Такой подход необходим для:

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

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

Извлечение данных из IndexedDB всегда асинхронно. Использование await позволяет синхронизировать процесс:

import { get, set } from 'idb-keyval';
import { appStore } from './store';

export async function initializeStorage() {
  let settings = await get('settings', appStore);

  if (!settings) {
    settings = {
      theme: 'light',
      language: 'ru'
    };
    await set('settings', settings, appStore);
  }

  return { settings };
}

Особенности:

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

Блокировка рендера до завершения инициализации

Инициализация должна выполняться до вызова рендера UI. В чистом JavaScript это реализуется через async-обёртку:

import { initializeStorage } from './init';

async function bootstrap() {
  const initialData = await initializeStorage();

  startApp(initialData);
}

bootstrap();

Функция startApp запускает приложение, уже имея все необходимые данные.


Интеграция с React

В React важно исключить рендер до получения данных. Используется внешний bootstrap:

import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import { initializeStorage } from './init';

async function bootstrap() {
  const initialData = await initializeStorage();

  const root = createRoot(document.getElementById('root'));
  root.render(<App initialData={initialData} />);
}

bootstrap();

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

  • отсутствие промежуточных состояний;
  • компоненты получают готовые данные через props;
  • нет необходимости в useEffect для первичной загрузки.

Интеграция с глобальными состояниями

Redux

import { createStore } from 'redux';
import rootReducer from './reducers';
import { initializeStorage } from './init';

async function bootstrap() {
  const initialData = await initializeStorage();

  const store = createStore(rootReducer, initialData);

  renderApp(store);
}

Zustand

import create from 'zustand';
import { initializeStorage } from './init';

export const useStore = create(() => ({}));

async function bootstrap() {
  const initialData = await initializeStorage();

  useStore.setState(initialData);

  renderApp();
}

В обоих случаях:

  • состояние формируется до рендера;
  • отсутствует “пустой” initial state;
  • не требуется дополнительная синхронизация.

Параллельная загрузка данных

Если требуется загрузка нескольких ключей, используется Promise.all:

import { get } from 'idb-keyval';
import { appStore } from './store';

export async function initializeStorage() {
  const [settings, user, cache] = await Promise.all([
    get('settings', appStore),
    get('user', appStore),
    get('cache', appStore)
  ]);

  return {
    settings: settings || {},
    user: user || null,
    cache: cache || {}
  };
}

Это уменьшает время инициализации.


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

IndexedDB может быть недоступен (например, в режиме инкогнито). Обработка ошибок обязательна:

export async function initializeStorage() {
  try {
    const settings = await get('settings', appStore);
    return { settings: settings || {} };
  } catch (error) {
    console.error('Storage initialization failed:', error);

    return {
      settings: {
        theme: 'light'
      }
    };
  }
}

Поведение:

  • приложение продолжает работать;
  • используется fallback-состояние;
  • ошибка логируется.

Кэширование результатов инициализации

Чтобы избежать повторных вызовов, можно кэшировать результат:

let cachedInit = null;

export async function initializeStorage() {
  if (cachedInit) return cachedInit;

  cachedInit = (async () => {
    const settings = await get('settings', appStore);
    return { settings: settings || {} };
  })();

  return cachedInit;
}

Это особенно полезно при повторных вызовах в сложных архитектурах.


Lazy-инициализация против ранней инициализации

Ранняя инициализация (до рендера):

  • стабильный UI;
  • предсказуемое состояние;
  • отсутствие “загрузочных” эффектов.

Lazy-инициализация (после рендера):

  • быстрее первый рендер;
  • сложнее управление состоянием;
  • необходимость отображения loading-состояний.

Выбор зависит от требований, но для критичных данных предпочтительна ранняя инициализация.


Структурирование кода

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

/storage
  store.js
  init.js
  keys.js

Пример keys.js:

export const STORAGE_KEYS = {
  SETTINGS: 'settings',
  USER: 'user',
  CACHE: 'cache'
};

Использование констант предотвращает ошибки и упрощает рефакторинг.


Расширение: миграции данных

При изменении структуры данных требуется миграция:

export async function initializeStorage() {
  let settings = await get('settings', appStore);

  if (settings && !settings.version) {
    settings = {
      ...settings,
      version: 2,
      newField: true
    };

    await set('settings', settings, appStore);
  }

  return { settings };
}

Подход:

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

Производительность

Факторы, влияющие на скорость:

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

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

  • объединять запросы (Promise.all);
  • минимизировать количество ключей;
  • избегать лишних записей при инициализации.

Итоговая схема работы

  1. Создаётся store (Store).
  2. Выполняется асинхронная функция инициализации.
  3. Загружаются данные (параллельно при необходимости).
  4. Применяются значения по умолчанию.
  5. Обрабатываются ошибки.
  6. Возвращается готовое состояние.
  7. Только после этого происходит рендер приложения.

Такая схема обеспечивает детерминированное поведение интерфейса и упрощает дальнейшую работу с локальным хранилищем.