В одном из своих проектов на 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.
Комментарии (4)

adminNiochen
14.01.2026 18:50Ой как удобно в изначальном примере добавить юзэффект который асинхронно исполняется, а потом искать серебряную пулю чтобы решить проблему с мигающим интерфейсом которую сами же создали. useState умеет принимать функцию-инициализатор если что, useLayoutEffect тоже существует.
Ну и чо-то я не понял как будет типизация работать если ты по одному ключу разные данные попытаешься записать? Локал сторадж-то общий. Если у тебя один компонент пишет поле counter и другой пишет counter то приплыли. Или ещё лучше - два экземпляра одного и того же компонента.

hardlight Автор
14.01.2026 18:50Первый пример намеренно гипертрофирован и показывает худший случай, да. Ниже указаны какие могут быть проблемы в целом при работе с local storage напрямую и через хуки, в том числе и через useState и useLayoutEffect.
Про два экземпляра одного и того же компонента, то как раз эта статья показывает как сделать чтобы при обновлении состояния в одном компоненте, второй получал то же значение, так же как если бы это было сделано через общий стор в redux или zustand. Если нужно чтобы два экземпляра одного и того же компонента считали counter независимо друг от друга, то стоит использовать локальный стейт через useState, а не local storage
DmitryOlkhovoi
сейчас практически любой стейт менеджер из коробки или плагином умеет в локалстор. Да и даже если просто, не стоит писать велосипед. Если не хватает нативного апи, лучше взять какое-то готовое решение.