В одном из своих проектов на React мне нужно было хранить данные между разными заходами пользователя на страницу и при этом использовать их сразу при открытии — не дожидаясь, пока сервер “лениво” вернёт ответ.
Самый простой способ — использовать готовое API браузера: localStorage. Так я и подумал сначала. Но на практике работа с ним приносит больше неудобств, чем кажется. Давайте рассмотрим это на примере:
const [value, setValue] = useState(null); useEffect(() => { const stored = localStorage.getItem("key"); setValue(JSON.parse(stored)); }, []);
Выглядит достаточно просто, но здесь скрыто несколько проблем:
Сначала пользователь видит значение
null, а потом уже актуальное значение из localStorage → возникает мигающий UI.В localStorage данные хранятся как строка. При чтении через
JSON.parseмы можем получить исключение и "сломать" компонент.Мы не можем нормально типизировать данные из localStorage: они в коде превращаются в
any, и мы не можем проверить, что там лежит именно нужный тип.Если у нас есть два компонента, которые читают одно и то же значение из localStorage, то при изменении состояния в одном из них второй об этом не узнает — localStorage сам по себе не уведомляет о локальных изменениях внутри страницы.
Сам localStorage живёт вне жизненного цикла React-приложения и может приводить к проблемам в условиях concurrent rendering.
Компонент фактически становится
client-onlyиз-за использованияuseEffectи отсутствия localStorage в Node-окружении (SSR).
Шаг 1. Типизированная обёртка над localStorage
Начнём с типизированной обёртки над localStorage, чтобы гарантировать, что мы записываем и ожидаем только корректные данные. (Как гарантировать, что мы читаем корректные данные — обсудим позже)
Сначала добавим типизацию:
export type TypedStorageValue = Record<string, unknown>; export type TypedStorage = { get<K extends Extract<keyof S, string>>(key: K): S[K] | null; set<K extends Extract<keyof S, string>>(key: K, value: S[K]): S[K] | null; remove(key: Extract<keyof S, string>): void; clear(): void; };
Теперь напишем реализацию. Сразу сделаем её через фабрику — это даст преимущества дальше:
// typedStorage.ts export function createTypedStorage(): TypedStorage { type Keys = Extract<keyof S, string>; type Value = S[K]; const isClient = typeof window !== "undefined"; const getStorage = (): Storage | null => { if (!isClient) return null; return window.localStorage; }; return { get<K extends Keys>( key: K, ): Value<K> | null { const storage = getStorage(); if (!storage) return null; try { const raw = storage.getItem(key); if (!raw) return null; return JSON.parse(raw); } catch (error) { console.warn(`Invalid data for key "${key}":`, error); return null; } }, set<K extends Keys>(key: K, value: Value<K>): Value<K> | null { const storage = getStorage(); if (!storage) return null; try { const raw = JSON.stringify(value); storage.setItem(key, raw); return value; } catch (error) { console.error(`Failed to save key "${key}":`, error); return null; } }, remove(key: Keys): void { getStorage()?.removeItem(key); }, clear() { getStorage()?.clear(); }, }; }
Шаг 2. Хук для React: почему не useState
Но как использовать это в компонентах? Сейчас это не похоже на типичный хук React. Давайте напишем хук, который можно легко переиспользовать.
Как я писал выше, localStorage живёт вне жизненного цикла React и является внешним хранилищем. В таких случаях useState лишь создаёт дополнительные места для рассинхронизации (мы будем хранить копию значения, которая может устареть). Это именно та ситуация, под которую начиная с React 18 добавили useSyncExternalStore: он позволяет синхронно читать состояние и корректно подписываться на обновления.
Подробнее — в документации React.
// use-typed-storage-item.ts export function useTypedStorageItem<S extends TypedStorageValue, K extends Extract<keyof S, string>>( key: K, { storage }: { storage: TypedStorage<S>; } ) { const isClient = typeof window !== "undefined"; const customEventName = `storage-${key}`; const subscribe = useCallback( (callback: () => void) => { if (!isClient) return noop; // использование callback мы напишем позже }, [isClient], ); const getSnapshot = useCallback(() => storage?.get(key) ?? null, [key, storage]); const value = useSyncExternalStore( subscribe, getSnapshot, () => null, ); const set = useCallback( (val: S[K]) => storage.set(key, val), [key, storage], ); const remove = useCallback(() => storage?.remove(key), [key, storage]); return useMemo(() => ({ value, set, remove }), [value, set, remove]); }
Таким образом мы можем использовать этот хук в компоненте:
function ThemeToggle() { const { value, set } = useTypedStorageItem('theme', { storage: myStorage }); return ( <button onClick={() => set(value === 'light' ? 'dark' : 'light')}> Current mode: {value} </button> ); }
Шаг 3. Синхронизация между компонентами на одной странице
Как гарантировать, что при использовании хука в двух разных компонентах мы будем видеть одно и то же значение в value? У нас ведь два независимых хука, и каждый читает состояние сам.
Браузер позволяет отправлять кастомные события через window.dispatchEvent и подписываться на них через window.addEventListener. Пусть наша типизированная обёртка при изменении данных будет диспатчить событие, а каждый хук — слушать его. Тогда мы сможем синхронизировать состояние между компонентами на одной странице.
Добавим отправку событий в обёртку:
// typedStorage.ts // ... set<K extends Keys>(key: K, value: Value<K>): Value<K> | null { // ... storage.setItem(key, raw); if (isClient) { window.dispatchEvent(new CustomEvent(`storage-${key}`)); } return value; // ... }, remove(key: Keys): void { getStorage()?.removeItem(key); if (isClient) { window.dispatchEvent(new CustomEvent(`storage-${key}`)); } }, clear() { getStorage()?.clear(); if (isClient) { window.dispatchEvent(new CustomEvent(`clear-storage`)); } },
По аналогии с событием изменения конкретного ключа добавим случай с очисткой всего хранилища.
Теперь реализуем subscribe внутри хука:
// use-typed-storage-item.ts const subscribe = useCallback((callback: () => void) => { if (!isClient) return () => undefined; window.addEventListener(customEventName, callback); window.addEventListener('clear-storage', callback); return () => { window.removeEventListener(customEventName, callback); window.removeEventListener('clear-storage', callback); }; }, [key, customEventName, isClient], );
Дальнейшие улучшения
Мы разобрали основные моменты, но здесь всё ещё есть пространство для улучшений. Давайте последовательно пройдёмся по ним: зачем они нужны и как их реализовать.
Улучшение 1. Синхронизация между вкладками/страницами
Сейчас компоненты синхронизируются внутри одной страницы. Но если значение изменится в другой вкладке или на другой странице, наше кастомное событие не сработает.
Для этого есть встроенное браузерное событие "storage", в котором передаётся изменённый ключ (или null, если хранилище было очищено). Добавим обработку этого события в subscribe:
// use-typed-storage-item.ts const subscribe = useCallback((callback: () => void) => { if (!isClient) return () => undefined; const storageHandler = (e: StorageEvent) => { if (e.key === key || e.key === null) callback(); }; window.addEventListener("storage", storageHandler); return () => { window.removeEventListener("storage", storageHandler); }; }, [key, customEventName, isClient], );
Таким образом наши кастомные события ловят изменения в рамках одной страницы same-tab, а встроенное событие "storage" изменения между страницами, cross-tab
Улучшение 2. Поддержка sessionStorage
Сейчас мы работаем только с localStorage. Но можно использовать тот же подход и для sessionStorage. Модифицируем фабрику, чтобы она поддерживала оба варианта:
// typedStorage.ts type StorageType = "localStorage" | "sessionStorage"; function createTypedStorage( type: StorageType = "localStorage" ): TypedStorage { const getStorage = (): Storage | null => { if (!isClient) return null; return type === "localStorage" ? window.localStorage : window.sessionStorage; }; // ... }
Улучшение 3. Значение по умолчанию
Сейчас, если значение не задано, мы возвращаем null. Добавим поддержку значения по умолчанию в интерфейс:
export type TypedStorage<S extends TypedStorageValue> = { get<K extends Extract<keyof S, string>>( key: K, options?: { defaultValue?: S[K] | undefined; } ): S[K] | null; // ... };
Улучшение 4. Валидация при чтении и записи
Хотя мы и знаем, что мы записываем в storage ожидаемые типы, мы не защищены от ситуации, когда данные уже лежат там (или были изменены вручную/расширениями/другим кодом).
Добавим опциональную валидацию:
// typedStorage.ts type StorageValidator = (value: unknown) => T; get(key: K, options?: { defaultValue?: Value; validate?: StorageValidator<S[K]>; }): Value | null { const storage = getStorage(); if (!storage) return options?.defaultValue ?? null; try { const raw = storage.getItem(key); if (!raw) return options?.defaultValue ?? null; let parsed = JSON.parse(raw); if (options?.validate && parsed !== null) { parsed = options.validate(parsed); } return parsed; } catch (error) { console.warn(`Invalid data for key "${key}":`, error); return options?.defaultValue ?? null; } }, set(key: K, value: Value, options?: { validate?: StorageValidator<S[K]> }): Value | null { const storage = getStorage(); if (!storage) return null; try { const valueToSave = options?.validate ? options.validate(value) : value; const raw = JSON.stringify(valueToSave); storage.setItem(key, raw); if (isClient) { window.dispatchEvent(new CustomEvent(`storage-${key}`)); } return valueToSave; } catch (error) { console.error(`Failed to save key "${key}":`, error); return null; } },
Улучшение 5. Кэширование, чтобы избежать лишних ререндеров
Если в localStorage лежит объект, то при JSON.parse мы каждый раз получаем новый объект. React сравнивает объекты по ссылке, поэтому компонент может перерендериваться чаще, чем ожидается.
Добавим простой кэш: ключом будет выступать сериализованная строка, лежащая в storage. Если строка не изменилась — будем возвращать тот же распарсенный объект (с той же ссылкой).
const cache = new Map<string, { raw: string | null; parsed: any }>(); get( key: K, options?: { defaultValue?: Value; validate?: StorageValidator<S[K]>; }, ): Value | null { // ... const raw = storage.getItem(key); const cached = cache.get(key); if (cached && cached.raw === raw) { return cached.parsed; } // ... }
Главное — не забыть обновлять кэш при изменениях в storage.
Улучшение 6. React < 18 и useSyncExternalStore
useSyncExternalStore есть только в React 18+. Что делать со старыми версиями?
Как минимум, я бы рекомендовал обновляться. Но если по техническим причинам это невозможно — есть готовая библиотека use-sync-external-store/shim, которая даёт полифилл этого хука для более старых версий. В React 18+ она использует нативный useSyncExternalStore.
Финальная версия
Финальная версия со всеми исправлениями и нужным функционалом:
// typedStorage.ts export function createTypedStorage( type: StorageType = "localStorage", ): TypedStorage { type Keys = Extract<keyof S, string>; type Value = S[K]; const isClient = typeof window !== "undefined"; const cache = new Map<string, { raw: string | null; parsed: any }>(); const getStorage = (): Storage | null => { if (!isClient) return null; return type === "localStorage" ? window.localStorage : window.sessionStorage; }; return { get<K extends Keys>( key: K, options?: { defaultValue?: Value<K>; validate?: StorageValidator<S[K]>; }, ): Value<K> | null { const storage = getStorage(); if (!storage) return options?.defaultValue ?? null; try { const raw = storage.getItem(key); const cached = cache.get(key); if (cached && cached.raw === raw) { return cached.parsed; } if (!raw) return options?.defaultValue ?? null; let parsed = JSON.parse(raw); if (options?.validate && parsed !== null) { parsed = options.validate(parsed); } cache.set(key, { raw, parsed }); return parsed; } catch (error) { console.warn(`[${type}] Invalid data for key "${key}":`, error); return options?.defaultValue ?? null; } }, set<K extends Keys>(key: K, value: Value<K>, options?: { validate?: StorageValidator<S[K]> }): Value<K> | null { const storage = getStorage(); if (!storage) return null; try { const valueToSave = options?.validate ? options.validate(value) : value; const raw = JSON.stringify(valueToSave); storage.setItem(key, raw); cache.set(key, { raw, parsed: valueToSave }); if (isClient) { window.dispatchEvent(new CustomEvent(`storage-${key}`)); } return valueToSave; } catch (error) { console.error(`[${type}] Failed to save key "${key}":`, error); return null; } }, remove(key: Keys): void { getStorage()?.removeItem(key); cache.delete(key); if (isClient) { window.dispatchEvent(new CustomEvent(`storage-${key}`)); } }, clear() { getStorage()?.clear(); cache.clear(); if (isClient) { window.dispatchEvent(new CustomEvent(‘clear-storage’)); } }, }; }
// use-typed-storage-item.ts export function useTypedStorageItem<S extends TypedStorageValue, K extends Extract<keyof S, string>>( key: K, { storage, defaultValue, validate }: { storage: TypedStorage; defaultValue?: S[K] | undefined; validate?: StorageValidator<S[K]>; }, ) { const isClient = typeof window !== "undefined"; const customEventName = `storage-${key}`; const subscribe = useCallback( (callback: () => void) => { if (!isClient) return noop; const storageHandler = (e: StorageEvent) => { if (e.key === key || e.key === null) callback(); }; window.addEventListener("storage", storageHandler); window.addEventListener(`storage-${key}`, callback); window.addEventListener("clear-storage", callback); return () => { window.removeEventListener("storage", storageHandler); window.removeEventListener(`storage-${key}`, callback); window.removeEventListener("clear-storage", callback); }; }, [key, customEventName, isClient], ); const getSnapshot = useCallback(() => storage?.get(key, { defaultValue, validate }) ?? null, [key, storage, defaultValue, validate]); const value = useSyncExternalStore( subscribe, getSnapshot, () => defaultValue ?? null, ); const set = useCallback( (val: S[K]) => storage.set(key, val, { validate }), [key, storage, customEventName], ); const remove = useCallback(() => storage?.remove(key), [key, storage, customEventName]); return useMemo(() => ({ value, set, remove }), [value, set, remove]); }
Заключение
Да, localStorage не является идеальным хранилищем. Он не предназначен для больших объёмов данных, может быть изменён пользователем, может быть медленным, и ни в коем случае не является заменой Redux/Zustand или других библиотек управления состоянием.
Но при этом он всё ещё полезен в некоторых сценариях — главное, использовать его более безопасно и удобно.
Всё, что описано в этой статье, я вынес в небольшую библиотеку, полностью совместимую с SSR и работающую с любой версией React старше 16, чтобы не дублировать эту логику в каждом проекте.
Вы можете установить её через npm i use-sync-typed-storage или посмотреть исходники на GitHub.
