如何在 React 中使用 Local Storage 持久化状态
使用可复用的 hook 将 React 状态持久化到 localStorage:useState 懒加载初始化、JSON try/catch、SSR 保护和跨标签同步。
要在 localStorage 中持久化 React 状态,需在初始化函数内部从存储中初始化 useState,并在值发生变化时将其写回——同时用 try/catch 包裹 JSON 操作,并防范服务端渲染问题。
每个 React 应用最终都会遇到这种需求,通常是主题切换或需要保持折叠状态的侧边栏。三行代码的版本只需五分钟就能写完,但之后往往会悄悄地让你花上一个下午来排查问题。这种简单粗暴的版本对单标签页的计数器来说没问题,但它会以三种可预见的方式出错:在数据损坏时崩溃、在 Next.js 中抛出 window is not defined,以及在多标签页之间出现状态过时的问题。本文将逐步构建一个 useLocalStorage Hook,沿着正确性阶梯逐一修复每种故障模式,最终提供一个可以直接粘贴到 React 18 或 19 项目中的即用型 Hook。
localStorage 是一个同步的、同源的、仅支持字符串的键值存储,每个源大约有 5MB 的容量,详见 MDN Web Storage API。在写任何代码之前,有一条铁律:永远不要在其中存储身份验证令牌或个人身份信息(PII)。它可以被页面上的任何 JavaScript 读取,且未经加密。
核心要点
- 在
useState的初始化函数内部读取localStorage,这样查找操作只在挂载时执行一次,而不是通过useEffect先闪现默认值。 - 由于
localStorage只存储字符串,写入时使用JSON.stringify,读取时使用JSON.parse,并用try/catch包裹,以防单个损坏的值导致组件崩溃。 - 服务端没有
window,因此在首次渲染期间读取存储会在 Next.js 和 Remix 中抛出window is not defined。应在服务端渲染默认值,并在挂载后同步到持久化的值。 - 浏览器的
storage事件只在其他标签页中触发,而不会在写入值的那个标签页中触发,因此同标签页的监听器需要手动派发事件。 - React 18 新增的
useSyncExternalStore是官方推荐的方式,用于将组件订阅到像localStorage这样的外部可变存储。
简单粗暴的 React localStorage 模式
起点是一个懒初始化的 useState 配合一个写入 effect。在 React 中,应在 useState 的初始化函数内部读取 localStorage,使查找操作只在挂载时执行一次,而不是在 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 包裹解析操作,这样单个损坏或遗留的值就不会导致组件崩溃。一种常见的生产环境故障模式是:schema 变更或半写入的值在某个键下留下了无效的 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 Hook?
将该模式封装成一个镜像 useState 接口的 Hook,使其可以作为直接替代品使用。为了与 useState 保持一致,useLocalStorage 的 setter 必须支持函数式更新,使 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” 与 hydration 不匹配
服务端没有 window 或 localStorage,因此在首次渲染期间读取存储会在 Next.js 和 Remix 中抛出 window is not defined。需要用 typeof window === 'undefined' 进行守卫,并在挂载后再读取持久化的值。
即使在阻止崩溃之后,还存在第二个更隐蔽的 bug。Hydration 不匹配的原因是:服务端渲染的是默认状态,而客户端已经有了存储的值;React 的首次客户端渲染必须与服务端 HTML 一致,因此如果在 hydration 期间的初始化函数中读取 localStorage,标记就会产生差异。解决方案是在服务端渲染默认值,然后在 hydration 完成后通过 effect 同步到持久化的值。
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]);
// ...setter 与之前相同
}
initializeWithValue 标志镜像了 usehooks-ts useLocalStorage 中的开关:在 SSR 场景下将其设为 false,使 Hook 在服务端返回默认值,并在 hydration 后进行同步。这类 bug 在干净的 localhost 加载中几乎不可见。通过回放真实的生产会话,往往才能真正看到 hydration 闪烁(默认主题在一帧内绘制出来,然后才被持久化的值替换),因为它依赖于时序和环境,而不是可以随时复现的问题。
跨标签页同步与现代 useSyncExternalStore 方案
当用户打开两个标签页时,持久化状态应保持一致。浏览器的 storage 事件(详见 MDN 的 Window: storage event)只在其他标签页和文档中触发,而不会在写入值的那个标签页中触发。因此,跨标签页同步需要一个 storage 监听器,而同标签页的监听器则需要手动派发自定义事件。
对于新代码,有一个比 useState + effects 更简洁的原语。useSyncExternalStore 在 React 18 中引入,是将组件订阅到外部可变存储的官方方式。组件通常从 props、state 和 context 中读取数据,但偶尔需要读取存在于 React 之外且随时间变化的值,包括那些持有可变值并在值变化时发出事件的浏览器 API。React 官方文档中关于该 Hook 的参考 建议在可能的情况下使用内置状态,并将其主要保留用于与现有非 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,于 2025 年 10 月 1 日发布,此后的 19.2.x 补丁版本列于 React 更新日志中。
| 选项 | 最适合场景 | SSR 处理方式 | 备注 |
|---|---|---|---|
| 手写 Hook | 一次性原始值,完全掌控 | typeof window 守卫 + 挂载后 effect | 边界情况由你自己负责 |
| usehooks-ts | 带 removeValue 的即用型 Hook | initializeWithValue: false | 基于 useState + 事件构建,而非 useSyncExternalStore |
| use-local-storage-state | 跨标签页 + 并发正确性 | 基于 useSyncExternalStore 构建 | 广泛使用;维护者注意到正在 hydrating 的组件可能会渲染两次 |
以下是完整的手写 Hook,在 React 18 和 19 上均正确,包含懒初始化、try/catch JSON 处理、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; // SSR 场景下设为 false
};
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 hydration),并在 key 变化时同步。
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(原始值或已记忆化的对象),以避免 effect 依赖项在每次渲染时都发生变化。
持久化 React 状态是一个循序渐进的过程,而非一行代码就能搞定:从懒初始化的 useState 和写入 effect 开始,添加 JSON try/catch,加入 SSR 守卫,再接入跨标签页事件。将上述 Hook 放入共享的 hooks/ 文件中,对需要在刷新后保留的状态将 useState 替换为它;一旦跨标签页的并发正确性开始变得重要,就转向 useSyncExternalStore 或维护良好的库。
常见问题
localStorage 和 sessionStorage 在持久化 React 状态方面有何区别?
两者都是同步的、同源的、仅支持字符串的键值存储,容量约为 5MB,但生命周期不同。localStorage 会无限期持久化,直到被显式清除,因此状态在刷新、关闭标签页和重启浏览器后依然存在。sessionStorage 的作用域限于单个标签页会话,当该标签页关闭时会被清除,且不在标签页之间共享。对于应超出会话生命周期的偏好设置,使用 localStorage;对于每个标签页的临时状态,使用 sessionStorage。
为什么不应该使用 Redux Persist 或全局 store 来持久化单个状态?
为了保存一个值而引入 Redux Persist 这样的全局 store,需要为本地 Hook 就能处理的状态额外配置 store、中间件和序列化设置。useLocalStorage Hook 将值与拥有它的组件放在一起,并镜像了 useState 的人体工程学,包括函数式更新。当你已经在运行 Redux store 且需要整个 slice 的重新 hydration 时,Redux Persist 才物有所值,而不是用于主题切换或单个表单字段。
当 localStorage 已满或在隐私浏览模式下被禁用时会发生什么?
当约 5MB 的源配额被超出时,写入 localStorage 会抛出 QuotaExceededError,而某些浏览器在隐私或无痕模式下会对任何写入操作抛出异常,因为配额被设为零。未加保护的 setItem 会导致组件崩溃,这就是为什么健壮的 Hook 中的 setter 要用 try/catch 包裹写入操作。读取操作也应回退到默认值,这样当存储被阻止或已满时,会降级为内存状态,而不是破坏渲染。
useSyncExternalStore 是否完全取代了 useState 加 useEffect 的 localStorage 模式?
并非适用于所有场景。React 18 新增的 useSyncExternalStore 是以并发安全的方式将组件订阅到外部可变存储的正确选择,当跨标签页正确性和并发渲染很重要时应使用它。React 官方文档建议在可能的情况下使用内置状态,并将该 Hook 保留用于集成非 React store。对于单个仅客户端的原始值,懒初始化的 useState 加写入 effect 仍然更简单且正确;当标签页之间需要保持同步时,再采用 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