Персистентность состояния Jotai через atomWithStorage

Персистентность состояния — ключевая задача в клиентских приложениях. В контексте Jotai она решается с помощью утилиты atomWithStorage, позволяющей автоматически синхронизировать значение атома с внешним хранилищем. В браузере таким хранилищем может быть localStorage, sessionStorage или более продвинутый механизм — IndexedDB, доступ к которому удобно реализуется через библиотеку idb-keyval.

atomWithStorage инкапсулирует логику чтения, записи и подписки на изменения хранилища, избавляя от необходимости вручную синхронизировать состояние.


Базовая работа atomWithStorage

Простейший пример:

import { atomWithStorage } from 'jotai/utils'

const countAtom = atomWithStorage('count', 0)
  • 'count' — ключ в хранилище
  • 0 — значение по умолчанию

Поведение:

  • при инициализации атом читает значение из storage
  • при изменении автоматически сохраняет его
  • поддерживает синхронизацию между вкладками (для localStorage)

Ограничения стандартного storage

По умолчанию используется localStorage, который:

  • синхронный
  • ограничен по объёму (~5MB)
  • не подходит для сложных структур данных
  • блокирует основной поток при интенсивных операциях

Для более серьёзных задач применяется IndexedDB — асинхронное, масштабируемое хранилище.


Использование idb-keyval как backend

idb-keyval — лёгкая обёртка над IndexedDB с простым API:

import { get, set, del } from 'idb-keyval'

Основные функции:

  • get(key) — получение значения
  • set(key, value) — запись
  • del(key) — удаление

Создание кастомного storage для atomWithStorage

atomWithStorage принимает третий аргумент — кастомный storage:

const customStorage = {
  getItem: async (key) => {
    return (await get(key)) ?? null
  },
  setItem: async (key, value) => {
    await set(key, value)
  },
  removeItem: async (key) => {
    await del(key)
  }
}

Теперь атом:

const userAtom = atomWithStorage('user', null, customStorage)

Особенности асинхронного storage

В отличие от localStorage, IndexedDB работает асинхронно. Это влияет на поведение:

  • начальное значение атома — initialValue
  • реальное значение подтягивается позже
  • возможна кратковременная рассинхронизация UI

Решение проблемы начальной загрузки

Для корректной работы с асинхронным storage важно учитывать состояние загрузки.

Подход 1: использование null как “не загружено”:

const userAtom = atomWithStorage('user', null, customStorage)

Подход 2: отдельный атом состояния загрузки:

const isLoadedAtom = atom(false)

Обработка JSON-сериализации

atomWithStorage автоматически сериализует данные через JSON.stringify и JSON.parse.

При использовании кастомного storage с idb-keyval сериализация не обязательна, так как IndexedDB поддерживает хранение объектов напрямую:

const customStorage = {
  getItem: async (key) => await get(key),
  setItem: async (key, value) => await set(key, value),
  removeItem: async (key) => await del(key),
}

Это даёт преимущества:

  • сохранение сложных структур (Map, Date, Blob)
  • отсутствие лишних преобразований

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

Типизация атома:

type User = {
  id: string
  name: string
}

const userAtom = atomWithStorage<User | null>(
  'user',
  null,
  customStorage
)

Типизация кастомного storage:

type AsyncStorage<T> = {
  getItem: (key: string) => Promise<T | null>
  setItem: (key: string, value: T) => Promise<void>
  removeItem: (key: string) => Promise<void>
}

Обновление состояния

Работа с атомом остаётся стандартной:

const [, setUser] = useAtom(userAtom)

setUser({ id: '1', name: 'Alice' })

Запись в IndexedDB произойдёт автоматически.


Очистка данных

Удаление значения:

setUser(null)

или напрямую:

await customStorage.removeItem('user')

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

localStorage поддерживает событие storage, позволяющее синхронизировать состояние между вкладками.

IndexedDB такой возможности не предоставляет напрямую. Возможные решения:

  1. BroadcastChannel API:
const channel = new BroadcastChannel('app')

channel.postMessage({ key: 'user', value })
  1. Событийная система поверх storage

Кэширование и производительность

IndexedDB быстрее при больших объёмах данных, но имеет накладные расходы:

  • асинхронность
  • транзакции

Оптимизации:

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

Разделение ответственности

Хорошая практика — вынести storage в отдельный модуль:

// storage.js
export const idbStorage = {
  getItem: async (key) => await get(key),
  setItem: async (key, value) => await set(key, value),
  removeItem: async (key) => await del(key),
}

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

const settingsAtom = atomWithStorage('settings', {}, idbStorage)

Работа с несколькими атомами

Каждый атом использует свой ключ:

const themeAtom = atomWithStorage('theme', 'light', customStorage)
const authAtom = atomWithStorage('auth', null, customStorage)

Миграции данных

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

const customStorage = {
  getItem: async (key) => {
    const data = await get(key)
    
    if (data && data.version !== 2) {
      return migrate(data)
    }
    
    return data
  },
  setItem: async (key, value) => {
    await set(key, { ...value, version: 2 })
  },
}

Ошибки и обработка исключений

IndexedDB может выбрасывать ошибки:

const safeStorage = {
  getItem: async (key) => {
    try {
      return await get(key)
    } catch {
      return null
    }
  },
  setItem: async (key, value) => {
    try {
      await set(key, value)
    } catch (e) {
      console.error(e)
    }
  },
  removeItem: async (key) => {
    try {
      await del(key)
    } catch (e) {
      console.error(e)
    }
  },
}

SSR и гидратация

При серверном рендеринге:

  • IndexedDB недоступен
  • код должен выполняться только в браузере

Проверка:

const isBrowser = typeof window !== 'undefined'

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

const storage = isBrowser ? customStorage : undefined

const atom = atomWithStorage('key', initialValue, storage)

Паттерн lazy hydration

Отложенная загрузка данных:

const baseAtom = atom(null)

const hydratedAtom = atom(
  (get) => get(baseAtom),
  async (get, set, upd ate) => {
    se t(baseAtom, upd ate)
    await customStorage.setItem('key', update)
  }
)

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

atomWithStorage полностью совместим с Jotai DevTools, что позволяет:

  • отслеживать изменения
  • откатывать состояние
  • анализировать персистентные данные

Сценарии применения

  • сохранение пользовательских настроек
  • кэширование API-ответов
  • оффлайн-режим
  • состояние авторизации
  • сложные формы

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

  • использовать IndexedDB для данных > 10KB
  • не хранить временные данные в persistent storage
  • избегать частых перезаписей
  • централизовать storage-логику
  • учитывать асинхронную природу

Расширение функциональности

Возможна реализация:

  • шифрования данных перед сохранением
  • TTL (время жизни записи)
  • логирования операций

Пример TTL:

const customStorage = {
  getItem: async (key) => {
    const data = await get(key)
    if (!data) return null
    
    if (Date.now() > data.expiry) {
      await del(key)
      return null
    }
    
    return data.value
  },
  setItem: async (key, value) => {
    await se t(key, {
      value,
      expiry: Date.now() + 1000 * 60 * 60,
    })
  },
}

Итоговая архитектура

Связка:

  • Jotai (atomWithStorage)
  • кастомный storage
  • idb-keyval

позволяет построить:

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