Как сохранять состояние в localStorage с помощью React
Сохраняйте состояние React в localStorage с помощью переиспользуемого хука: ленивый useState, JSON try/catch, SSR-защита и синхронизация вкладок.
Чтобы сохранять состояние React в localStorage, инициализируйте useState из хранилища внутри функции-инициализатора и записывайте значение обратно при каждом его изменении — при этом оберните JSON в try/catch и добавьте защиту от серверного рендеринга.
Рано или поздно в каждом React-приложении появляется что-то подобное — как правило, переключатель темы или боковая панель, которая должна оставаться свёрнутой. Трёхстрочная версия пишется за пять минут, а потом тихо съедает полдня. Наивная реализация работает для счётчика в одной вкладке, но ломается тремя предсказуемыми способами: падает на повреждённых данных, выбрасывает window is not defined в Next.js и устаревает при работе в нескольких вкладках. В этой статье мы последовательно строим хук useLocalStorage, устраняя каждый из этих сбоев, и в итоге получаем готовый хук, который можно вставить в проект на React 18 или 19.
localStorage — это синхронное, привязанное к origin, строковое хранилище типа «ключ/значение» объёмом около 5 МБ на один origin, задокументированное в MDN Web Storage API. Одно правило до написания любого кода: никогда не храните в нём токены аутентификации или персональные данные. Оно доступно для чтения любому JavaScript-коду на странице и не зашифровано.
Ключевые выводы
- Читайте
localStorageвнутри инициализатораuseState, чтобы обращение к хранилищу происходило один раз при монтировании, а не вызывало мерцание значения по умолчанию черезuseEffect. - Поскольку
localStorageхранит только строки, используйтеJSON.stringifyпри записи иJSON.parseпри чтении, обёрнутые вtry/catch, чтобы одно повреждённое значение не могло уронить компонент. - На сервере нет объекта
window, поэтому чтение хранилища во время первого рендера выбрасываетwindow is not definedв Next.js и Remix. Рендерите значение по умолчанию на сервере и синхронизируйтесь с сохранённым значением после монтирования. - Событие
storageбраузера срабатывает только в других вкладках, но не в той, которая записала значение, поэтому слушателям в той же вкладке нужно вручную диспетчеризировать событие. useSyncExternalStore, добавленный в React 18, является официально поддерживаемым способом подписки компонента на внешнее изменяемое хранилище, такое какlocalStorage.
Наивный паттерн использования localStorage в React
Отправная точка — лениво инициализированный useState в паре с эффектом записи. В React читайте localStorage внутри функции-инициализатора useState, чтобы обращение к хранилищу происходило один раз при монтировании, а не в useEffect, который вызывал бы мерцание значения по умолчанию.
import { useState, useEffect } from 'react';
function ThemeToggle() {
const [theme, setTheme] = useState(() => {
return localStorage.getItem('theme') ?? 'light';
});
useEffect(() => {
localStorage.setItem('theme', theme);
}, [theme]);
return (
<button onClick={() => setTheme(t => (t === 'light' ? 'dark' : 'light'))}>
Theme: {theme}
</button>
);
}
Передача функции в useState (а не useState(localStorage.getItem(...))) принципиальна: ленивый инициализатор выполняется только при первом рендере, что позволяет избежать обращения к localStorage при каждом повторном рендере. Чтение в инициализаторе, а не в отдельном useEffect, также гарантирует, что корректное значение присутствует уже при первой отрисовке — без мерцания между значением по умолчанию и сохранённым.
Безопасная сериализация с JSON и try/catch
Discover how at OpenReplay.com.
Наивная версия работает только со строками. Поскольку localStorage хранит исключительно строки, для нестроковых состояний используйте JSON.stringify при записи и JSON.parse при чтении, обернув парсинг в try/catch, чтобы одно повреждённое или устаревшее значение не могло уронить компонент. Типичный сбой в продакшене — изменение схемы данных или частично записанное значение, оставляющее невалидный JSON под ключом; без защиты JSON.parse выбросит исключение при монтировании и обрушит компонент.
function readJSON<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback; // повреждённое или устаревшее значение → возврат к дефолтному
}
}
Ветка catch возвращает значение по умолчанию вместо того, чтобы пробрасывать исключение — это разница между сбросом одной настройки из-за плохого ключа и полным обнулением страницы.
Как создать переиспользуемый хук useLocalStorage?
Оберните паттерн в хук, который повторяет интерфейс useState и может служить его заменой. Чтобы сохранить паритет с useState, сеттер вашего useLocalStorage должен принимать функциональное обновление, то есть setValue(prev => prev + 1) должно работать так же, как со встроенным состоянием. Это эргономический пробел, который упускает большинство самописных реализаций.
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => readJSON(key, initialValue));
const set = useCallback(
(next: T | ((prev: T) => T)) => {
setValue(prev => {
const resolved = next instanceof Function ? next(prev) : next;
localStorage.setItem(key, JSON.stringify(resolved));
return resolved;
});
},
[key],
);
return [value, set] as const;
}
Проверка next instanceof Function сохраняет эргономику useState. Эта версия корректна на клиенте, но по-прежнему читает localStorage во время рендера, что ломается при серверном рендеринге.
Подводный камень SSR: «window is not defined» и несоответствие при гидратации
На сервере нет объектов window и localStorage, поэтому чтение хранилища во время первого рендера выбрасывает window is not defined в Next.js и Remix. Добавьте защиту через typeof window === 'undefined' и читайте сохранённое значение после монтирования.
Есть и второй, более тонкий баг, который проявляется даже после устранения краша. Несоответствие при гидратации возникает потому, что сервер рендерит состояние по умолчанию, тогда как клиент уже имеет сохранённое значение; первый клиентский рендер React должен совпадать с серверным HTML, поэтому чтение localStorage в инициализаторе во время гидратации приводит к расхождению разметки. Решение — рендерить значение по умолчанию на сервере, а затем синхронизироваться с сохранённым значением в эффекте после гидратации.
const IS_SERVER = typeof window === 'undefined';
function useLocalStorage<T>(key: string, initialValue: T, initializeWithValue = true) {
const readValue = () => (IS_SERVER ? initialValue : readJSON(key, initialValue));
const [value, setValue] = useState<T>(() =>
initializeWithValue ? readValue() : initialValue,
);
useEffect(() => {
setValue(readValue()); // синхронизация из хранилища после монтирования
}, [key]);
// ...сеттер как прежде
}
Флаг initializeWithValue повторяет переключатель из usehooks-ts useLocalStorage: установите его в false для SSR, чтобы хук возвращал значение по умолчанию на сервере и синхронизировался после гидратации. Этот класс ошибок практически незаметен при чистой загрузке на localhost. Воспроизведение реальной продакшен-сессии — нередко единственный способ увидеть мерцание при гидратации (когда тема по умолчанию отрисовывается на один кадр раньше сохранённого значения), поскольку оно зависит от тайминга и окружения, а не воспроизводится по требованию.
Синхронизация между вкладками и современный подход с useSyncExternalStore
Сохранённое состояние должно оставаться согласованным, когда пользователь открывает две вкладки. Событие storage браузера, описанное в MDN’s Window: storage event, срабатывает только в других вкладках и документах, но не в той, которая записала значение. Поэтому синхронизация между вкладками требует слушателя storage, а слушателям в той же вкладке нужно вручную диспетчеризировать пользовательское событие.
Для нового кода существует более чистый примитив, чем useState + эффекты. useSyncExternalStore был представлен в React 18 как официальный способ подписки компонента на внешнее изменяемое хранилище. Компоненты обычно читают из props, состояния и контекста, но иногда приходится читать значение, которое живёт за пределами React и меняется со временем — в том числе браузерные API, хранящие изменяемое значение и генерирующие события при его изменении. Официальная документация React по этому хуку рекомендует встроенное состояние там, где это возможно, и оставляет хук преимущественно для интеграции с существующим кодом вне React. localStorage подходит под это определение, и именно поэтому поддерживаемые библиотеки перешли на него для конкурентно-безопасного и корректного чтения между вкладками.
function useLocalStorageValue(key: string, initial: string) {
const subscribe = (cb: () => void) => {
window.addEventListener('storage', cb);
return () => window.removeEventListener('storage', cb);
};
return useSyncExternalStore(
subscribe,
() => localStorage.getItem(key) ?? initial,
() => initial, // серверный снимок
);
}
Стоит ли писать useLocalStorage самостоятельно или использовать библиотеку?
Пишите самостоятельно, когда нужен один простой примитив только на клиенте. Обращайтесь к поддерживаемой библиотеке, когда требуется совместная обработка граничных случаев сериализации, SSR и синхронизации между вкладками. Оба варианта ниже работают на React 18 и 19. Текущая версия — React 19.2, выпущенная 1 октября 2025 года; патч-релизы серии 19.2.x перечислены в журнале изменений React.
| Вариант | Лучше всего подходит для | Поддержка SSR | Примечания |
|---|---|---|---|
| Самописный хук | Разовые примитивы, полный контроль | Защита typeof window + эффект после монтирования | Граничные случаи на вашей ответственности |
| usehooks-ts | Готовый хук с removeValue | initializeWithValue: false | Построен на useState + события, не на useSyncExternalStore |
| use-local-storage-state | Корректность между вкладками и при конкурентном рендеринге | Построен на useSyncExternalStore | Широко используется; мейнтейнер отмечает, что гидрирующие компоненты могут рендериться дважды |
Ниже приведён полный самописный хук, корректный для React 18 и 19, с ленивой инициализацией, JSON в try/catch, защитой от SSR, функциональными обновлениями, removeValue и событиями как между вкладками, так и внутри одной вкладки:
import { useCallback, useEffect, useState } from 'react';
const IS_SERVER = typeof window === 'undefined';
type Options<T> = {
serializer?: (value: T) => string;
deserializer?: (value: string) => T;
initializeWithValue?: boolean; // установите false для SSR
};
export function useLocalStorage<T>(
key: string,
initialValue: T,
options: Options<T> = {},
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
const { initializeWithValue = true } = options;
const serialize = options.serializer ?? JSON.stringify;
const deserialize = options.deserializer ?? ((v: string) => JSON.parse(v) as T);
const readValue = useCallback((): T => {
if (IS_SERVER) return initialValue;
try {
const raw = window.localStorage.getItem(key);
return raw ? deserialize(raw) : initialValue;
} catch {
return initialValue;
}
}, [key, initialValue, deserialize]);
const [storedValue, setStoredValue] = useState<T>(() =>
initializeWithValue ? readValue() : initialValue,
);
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
try {
const next = value instanceof Function ? value(readValue()) : value;
window.localStorage.setItem(key, serialize(next));
setStoredValue(next);
window.dispatchEvent(new StorageEvent('local-storage', { key }));
} catch {
/* превышена квота или приватный режим — игнорируем */
}
},
[key, readValue, serialize],
);
const removeValue = useCallback(() => {
window.localStorage.removeItem(key);
setStoredValue(initialValue);
window.dispatchEvent(new StorageEvent('local-storage', { key }));
}, [key, initialValue]);
// Синхронизация из хранилища после монтирования (исправляет гидратацию SSR) и при смене ключа.
useEffect(() => {
setStoredValue(readValue());
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
// Слушатели для других вкладок ('storage') и текущей вкладки ('local-storage').
useEffect(() => {
const onChange = (event: Event) => {
const e = event as StorageEvent;
if (e.key && e.key !== key) return;
setStoredValue(readValue());
};
window.addEventListener('storage', onChange);
window.addEventListener('local-storage', onChange);
return () => {
window.removeEventListener('storage', onChange);
window.removeEventListener('local-storage', onChange);
};
}, [key, readValue]);
return [storedValue, setValue, removeValue];
}
Передавайте стабильное initialValue (примитив или мемоизированный объект), чтобы зависимости эффекта не пересчитывались при каждом рендере.
Сохранение состояния React — это лестница, а не однострочник: начните с лениво инициализированного useState и эффекта записи, добавьте JSON в try/catch, защиту от SSR, затем подключите события между вкладками. Поместите хук выше в общую папку hooks/, замените useState на него для того состояния, которое должно переживать обновление страницы, и переходите к useSyncExternalStore или поддерживаемой библиотеке, как только корректность при конкурентном рендеринге между вкладками станет важной.
Часто задаваемые вопросы
В чём разница между localStorage и sessionStorage для сохранения состояния React?
Оба являются синхронными, привязанными к origin, строковыми хранилищами типа «ключ/значение» объёмом около 5 МБ, но различаются временем жизни. localStorage хранит данные бессрочно до явной очистки, поэтому состояние переживает обновление страницы, закрытие вкладки и перезапуск браузера. sessionStorage ограничен сессией одной вкладки и очищается при её закрытии; между вкладками он не разделяется. Используйте localStorage для настроек, которые должны сохраняться между сессиями, и sessionStorage для временного состояния, привязанного к конкретной вкладке.
Почему не стоит использовать Redux Persist или глобальное хранилище для сохранения одного значения состояния?
Использование глобального хранилища вроде Redux Persist для сохранения одного значения добавляет store, middleware и конфигурацию сериализации для состояния, с которым уже справляется локальный хук. Хук useLocalStorage держит значение рядом с компонентом-владельцем и повторяет эргономику useState, включая функциональные обновления. Redux Persist оправдывает свой вес, когда вы уже используете Redux и вам нужна повторная гидратация целых срезов состояния, но не для переключателя темы или одного поля формы.
Что происходит, когда localStorage заполнен или отключён в режиме приватного просмотра?
Запись в localStorage выбрасывает QuotaExceededError при превышении квоты origin (~5 МБ), а некоторые браузеры выбрасывают исключение на любую запись в приватном или инкогнито-режиме, поскольку квота установлена в ноль. Незащищённый вызов setItem роняет компонент — именно поэтому сеттер в надёжном хуке оборачивает запись в try/catch. Чтение также должно возвращаться к значению по умолчанию, чтобы заблокированное или переполненное хранилище деградировало до состояния в памяти, а не ломало рендер.
Заменяет ли useSyncExternalStore паттерн useState + useEffect для localStorage полностью?
Не во всех случаях. useSyncExternalStore, добавленный в React 18, является конкурентно-безопасным способом подписки компонента на внешнее изменяемое хранилище и является правильным выбором, когда важна корректность между вкладками и при конкурентном рендеринге. Официальная документация React рекомендует встроенное состояние там, где это возможно, и оставляет хук для интеграции со сторонними хранилищами. Для одного клиентского примитива лениво инициализированный useState с эффектом записи остаётся более простым и корректным решением; переходите к useSyncExternalStore, когда вкладки должны оставаться синхронизированными.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k