2026年7月9日
React useLocalStorage Hook:SSR 安全的持久化状态(2026)
用户花两分钟在你的仪表盘上配置好筛选条件,按了下刷新,一切归零。useState 天生就是短命的——每次刷新都从头再来。所有人都知道的解法是 localStorage;而所有人手写的接线方式——用 useState 的初始化函数读存储,再用一个 useEffect 写回去——至少带着四个 bug:SSR 下会崩溃或水合不匹配、遇到损坏数据会抛异常、多个浏览器标签页之间会失去同步、两个组件用同一个 key 会各说各话。
useLocalStorage 就是把这一切都做对的那个 hook。它长得和 useState 一模一样,但值能在刷新后存活,不止能存字符串,跨标签页和跨组件都保持同步,而且在服务端渲染下也安全。下面写的全是真实的 @reactuses/core API,TypeScript 优先。
为什么不直接 useState + useEffect?
下面是大多数代码库里最终会出现的版本,它比看上去更容易翻车:
function usePersistedState(key: string, defaultValue: string) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key); // 🐛 见下文
return stored !== null ? JSON.parse(stored) : defaultValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
- SSR 下直接坏掉。 服务端没有
localStorage,初始化函数直接抛异常。加个typeof window守卫?那就把崩溃换成了水合不匹配:服务端渲染的是默认值,客户端一上来就渲染存储值,React 发出警告——更糟的情况是悄悄打了错误的 DOM 补丁。 - 遇到坏数据就抛异常。 对一个被手动改过、写到一半、或者由旧版应用存下的值跑
JSON.parse,整个组件跟着一起挂。 - 无视其他标签页。 用户在标签页 A 改了设置,标签页 B 一直显示——而且还在反复保存——那个过期的旧值,直到整页刷新。
- 无视其他组件。 两个组件都调用
usePersistedState('theme', …),各自持有一份useState。一个更新了,另一个不重渲染。同一个 key,两个真相。
每个 bug 都能手动修,而修完加起来,恰好就是一个好 hook 本来的样子。
useLocalStorage——刷新后还活着的 useState
API 刻意做成了 useState 的形状:返回值和 setter 组成的元组,默认值是第二个参数。
import { useLocalStorage } from '@reactuses/core';
function Settings() {
const [layout, setLayout] = useLocalStorage('dashboard-layout', 'grid');
return (
<select value={layout ?? 'grid'} onChange={(e) => setLayout(e.target.value)}>
<option value="grid">网格</option>
<option value="list">列表</option>
</select>
);
}
签名是 useLocalStorage(key, defaultValue, options?):
const [value, setValue] = useLocalStorage<T>(key, defaultValue, options);
// value: T | null setValue: Dispatch<SetStateAction<T | null>>
首次访问时 value 是默认值;任何一次 setValue 之后,值就被写进 localStorage,下次刷新原样回来。函数式更新和 useState 完全一致:setValue(prev => …) 拿到的是当前存储的值。和 useState 唯一肉眼可见的区别是类型:value 是 T | null,因为一个持久化的 key 还可以被删除——下文细说。
对象、Map、Set、Date——序列化全自动
localStorage 只能存字符串;hook 会根据默认值的类型自动挑选正确的序列化器。传对象就自动走 JSON.stringify/JSON.parse 往返;传数字拿回来的就是数字,而不是 "42":
const [filters, setFilters] = useLocalStorage('filters', {
status: 'open',
assignee: null as string | null,
});
const [fontSize, setFontSize] = useLocalStorage('font-size', 16);
const [seen, setSeen] = useLocalStorage('seen-ids', new Set<string>());
const [lastVisit, setLastVisit] = useLocalStorage('last-visit', new Date());
最后那两个正是手写版本永远不会处理的部分:Map、Set、Date 默认值有各自专属的序列化器(Set → JSON 数组、Date → ISO 字符串,再原样读回来),所以刷新之后 seen 依然是一个带 .has() 的真 Set——而不是一具字符串化的空壳。
内置序列化器不够用时——比如这个值要和其他系统写下的格式保持兼容——传你自己的:
const [config, setConfig] = useLocalStorage('legacy-config', defaultConfig, {
serializer: {
read: (raw) => parseLegacyFormat(raw),
write: (value) => toLegacyFormat(value),
},
});
删除 key:setValue(null)
持久化状态有一个 useState 没有的操作:把这个忘了。把值设成 null,key 会从 localStorage 里被整个移除:
const [token, setToken] = useLocalStorage<string>('auth-token', null);
// 登录
setToken(response.token);
// 登出——key 从 localStorage 删除,值变为 null
setToken(null);
这就是值的类型是 T | null 的原因。被删除的 key 在本次会话里会一直保持 null——不会弹回默认值——而这正是你想要的:「已登出」和「从未登录、显示默认值」是两个不同的状态,hook 不会把它们混为一谈。
SSR 与水合,真正的安全
useLocalStorage 构建在 useSyncExternalStore 之上——React 官方的外部数据订阅原语——服务端快照返回默认值。这一个设计决定换来三件事:
- 服务端不崩。 hook 在服务端渲染期间绝不触碰
window或localStorage。你的代码里不需要任何typeof window守卫。 - 没有水合不匹配。 客户端的首次渲染刻意与服务端 HTML(默认值)保持一致,然后 React 通过
useSyncExternalStore的正规路径重渲染出存储值——没有警告,没有被错误覆盖的 DOM。 - 并发安全的读取。 因为存储被当作外部 store 对待,React 18+ 的 transition 等特性永远不会读到撕裂的值。
有一件事是任何 localStorage hook 都消除不了的:存储值出现之前,默认值会闪现一瞬——服务端是真的不知道浏览器存储里有什么。对闪现很伤的值(主题色是经典案例),解法在 React 之外,靠一段阻塞的内联脚本;相关取舍见 SSR 安全的 React Hooks。
而当存储本身不可用时——某些隐私模式,或者访问存储直接抛异常——hook 会退化成一个纯内存的状态容器,并通过 onError 上报失败,而不是崩溃:
const [draft, setDraft] = useLocalStorage('draft', '', {
onError: (e) => trackWarning('storage unavailable', e), // 默认:console.error
});
同一个 onError 还会接住损坏数据(朴素版本里那个 JSON.parse bug——hook 会返回默认值而不是抛异常)以及超出配额的写入。
跨标签页同步——也跨组件
在一个标签页里改值,其他所有标签页立刻更新,因为 hook 监听了浏览器原生的 storage 事件:
// 标签页 A 和标签页 B 都渲染这段——在一边切换,两边都更新。
const [theme, setTheme] = useLocalStorage('theme', 'light');
跨标签页同步默认开启;如果某个标签页应该保持自己的视图直到刷新,用 listenToStorageChanges: false 关掉。
更隐蔽的另一半是同标签页的同步。原生 storage 事件永远不会在发起修改的那个标签页里触发,所以在手写 hook 里,头部的 theme 开关更新了头部——而读同一个 key 的侧边栏还抱着过期的副本。useLocalStorage 在内部会把每次写入重新广播一遍,所以同一个 key 上的所有组件永远一起重渲染。两个组件,一个 key,一个真相——朴素版本的漂移 bug 根本不存在。(如果你要跨标签页同步的不止是持久化状态,React 跨标签页状态有完整的工具箱。)
存储家族
useLocalStorage 有几个兄弟姐妹;按值应该活在哪里、活多久来选:
| Hook | 存在… | 存活周期 | 跨标签页 |
|---|---|---|---|
useLocalStorage | localStorage | 跨刷新 + 跨浏览器重启 | ✅ 同步 |
useSessionStorage | sessionStorage | 跨刷新,按标签页隔离 | ❌ 设计上就按标签页隔离 |
useCookie | cookie | 由 cookie 选项决定;会随请求发给服务端 | ✅ |
useBroadcastChannel | 不存储(纯消息) | — | ✅ 实时消息 |
useSessionStorage 的 API 和序列化行为完全一致——换个 import,值就变成按标签页隔离的。当服务端在第一个请求里就需要这个值时,该选的是 useCookie(这也是主题色闪现问题的真正解法)。useBroadcastChannel 根本不是存储,但当标签页之间需要对话而不是持久化时,它才是对的工具。
要点回顾
- 手写的
useState+useEffect+localStorage组合自带四个 bug:SSR 崩溃或水合不匹配、JSON.parse遇坏数据崩溃、没有跨标签页同步、共享 key 的组件之间漂移。 useLocalStorage(key, defaultValue)是能持久化的useState平替——同样的元组、同样的函数式更新,类型是T | null。- 序列化全自动,由默认值的类型驱动——对象、数组、数字、布尔值,甚至
Map、Set、Date都能正确往返。需要特定格式时传自定义serializer。 setValue(null)会删除 key——「已清除」是一个真实状态,和默认值是两回事。- 构建在
useSyncExternalStore之上:SSR 安全、无需守卫、没有水合不匹配,存储被禁用时退化为内存状态(配合onError)。 - 同步是全方位的:跨标签页靠原生
storage事件(用listenToStorageChanges开关),同标签页跨组件靠内部重新广播——始终开启。 - 同一套 API,不同的生命周期:按标签页隔离用
useSessionStorage,服务端也需要时用useCookie。
从 @reactuses/core 里把它拿走,让「刷新」不再等于「归零」。