Персистентность состояния — ключевая задача в клиентских приложениях.
В контексте Jotai она решается с помощью утилиты
atomWithStorage, позволяющей автоматически синхронизировать
значение атома с внешним хранилищем. В браузере таким хранилищем может
быть localStorage, sessionStorage или более
продвинутый механизм — IndexedDB, доступ к которому удобно реализуется
через библиотеку idb-keyval.
atomWithStorage инкапсулирует логику чтения, записи и
подписки на изменения хранилища, избавляя от необходимости вручную
синхронизировать состояние.
Простейший пример:
import { atomWithStorage } from 'jotai/utils'
const countAtom = atomWithStorage('count', 0)
'count' — ключ в хранилище0 — значение по умолчаниюПоведение:
localStorage)По умолчанию используется localStorage, который:
Для более серьёзных задач применяется IndexedDB — асинхронное, масштабируемое хранилище.
idb-keyval — лёгкая обёртка над IndexedDB с простым
API:
import { get, set, del } from 'idb-keyval'
Основные функции:
get(key) — получение значенияset(key, value) — записьdel(key) — удаление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)
В отличие от localStorage, IndexedDB работает
асинхронно. Это влияет на поведение:
initialValueДля корректной работы с асинхронным storage важно учитывать состояние загрузки.
Подход 1: использование null как “не загружено”:
const userAtom = atomWithStorage('user', null, customStorage)
Подход 2: отдельный атом состояния загрузки:
const isLoadedAtom = atom(false)
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),
}
Это даёт преимущества:
Типизация атома:
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 такой возможности не предоставляет напрямую. Возможные решения:
const channel = new BroadcastChannel('app')
channel.postMessage({ key: 'user', value })
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)
}
},
}
При серверном рендеринге:
Проверка:
const isBrowser = typeof window !== 'undefined'
Использование fallback:
const storage = isBrowser ? customStorage : undefined
const atom = atomWithStorage('key', initialValue, storage)
Отложенная загрузка данных:
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)
}
)
atomWithStorage полностью совместим с Jotai DevTools,
что позволяет:
Возможна реализация:
Пример 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,
})
},
}
Связка:
atomWithStorage)idb-keyvalпозволяет построить:
localStorage