React useLocalStorage Hook:SSR 安全的持久化状态(2026)

用户花两分钟在你的仪表盘上配置好筛选条件,按了下刷新,一切归零。useState 天生就是短命的------每次刷新都从头再来。所有人都知道的解法是 localStorage;而所有人手写的接线方式------用 useState 的初始化函数读存储,再用一个 useEffect 写回去------至少带着四个 bug:SSR 下会崩溃或水合不匹配、遇到损坏数据会抛异常、多个浏览器标签页之间会失去同步、两个组件用同一个 key 会各说各话。

useLocalStorage 就是把这一切都做对的那个 hook。它长得和 useState 一模一样,但值能在刷新后存活,不止能存字符串,跨标签页 跨组件都保持同步,而且在服务端渲染下也安全。下面写的全是真实的 @reactuses/core API,TypeScript 优先。

为什么不直接 useState + useEffect?

下面是大多数代码库里最终会出现的版本,它比看上去更容易翻车:

tsx 复制代码
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;
}
  1. SSR 下直接坏掉。 服务端没有 localStorage,初始化函数直接抛异常。加个 typeof window 守卫?那就把崩溃换成了水合不匹配:服务端渲染的是默认值,客户端一上来就渲染存储值,React 发出警告------更糟的情况是悄悄打了错误的 DOM 补丁。
  2. 遇到坏数据就抛异常。 对一个被手动改过、写到一半、或者由旧版应用存下的值跑 JSON.parse,整个组件跟着一起挂。
  3. 无视其他标签页。 用户在标签页 A 改了设置,标签页 B 一直显示------而且还在反复保存------那个过期的旧值,直到整页刷新。
  4. 无视其他组件。 两个组件都调用 usePersistedState('theme', ...),各自持有一份 useState。一个更新了,另一个不重渲染。同一个 key,两个真相。

每个 bug 都能手动修,而修完加起来,恰好就是一个好 hook 本来的样子。

useLocalStorage------刷新后还活着的 useState

API 刻意做成了 useState 的形状:返回值和 setter 组成的元组,默认值是第二个参数。

tsx 复制代码
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?)

ts 复制代码
const [value, setValue] = useLocalStorage<T>(key, defaultValue, options);
// value: T | null      setValue: Dispatch<SetStateAction<T | null>>

首次访问时 value 是默认值;任何一次 setValue 之后,值就被写进 localStorage,下次刷新原样回来。函数式更新和 useState 完全一致:setValue(prev => ...) 拿到的是当前存储的值。和 useState 唯一肉眼可见的区别是类型:valueT | null,因为一个持久化的 key 还可以被删除------下文细说。

对象、Map、Set、Date------序列化全自动

localStorage 只能存字符串;hook 会根据默认值的类型自动挑选正确的序列化器。传对象就自动走 JSON.stringify/JSON.parse 往返;传数字拿回来的就是数字,而不是 "42"

tsx 复制代码
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());

最后那两个正是手写版本永远不会处理的部分:MapSetDate 默认值有各自专属的序列化器(Set → JSON 数组、Date → ISO 字符串,再原样读回来),所以刷新之后 seen 依然是一个带 .has() 的真 Set------而不是一具字符串化的空壳。

内置序列化器不够用时------比如这个值要和其他系统写下的格式保持兼容------传你自己的:

tsx 复制代码
const [config, setConfig] = useLocalStorage('legacy-config', defaultConfig, {
  serializer: {
    read: (raw) => parseLegacyFormat(raw),
    write: (value) => toLegacyFormat(value),
  },
});

删除 key:setValue(null)

持久化状态有一个 useState 没有的操作:把这个忘了 。把值设成 null,key 会从 localStorage 里被整个移除:

tsx 复制代码
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 在服务端渲染期间绝不触碰 windowlocalStorage。你的代码里不需要任何 typeof window 守卫。
  • 没有水合不匹配。 客户端的首次渲染刻意与服务端 HTML(默认值)保持一致,然后 React 通过 useSyncExternalStore 的正规路径重渲染出存储值------没有警告,没有被错误覆盖的 DOM。
  • 并发安全的读取。 因为存储被当作外部 store 对待,React 18+ 的 transition 等特性永远不会读到撕裂的值。

有一件事是任何 localStorage hook 都消除不了的:存储值出现之前,默认值会闪现一瞬------服务端是真的不知道浏览器存储里有什么。对闪现很伤的值(主题色是经典案例),解法在 React 之外,靠一段阻塞的内联脚本;相关取舍见 SSR 安全的 React Hooks

而当存储本身不可用时------某些隐私模式,或者访问存储直接抛异常------hook 会退化成一个纯内存的状态容器,并通过 onError 上报失败,而不是崩溃:

tsx 复制代码
const [draft, setDraft] = useLocalStorage('draft', '', {
  onError: (e) => trackWarning('storage unavailable', e), // 默认:console.error
});

同一个 onError 还会接住损坏数据(朴素版本里那个 JSON.parse bug------hook 会返回默认值而不是抛异常)以及超出配额的写入。

跨标签页同步------也跨组件

在一个标签页里改值,其他所有标签页立刻更新,因为 hook 监听了浏览器原生的 storage 事件:

tsx 复制代码
// 标签页 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
  • 序列化全自动,由默认值的类型驱动------对象、数组、数字、布尔值,甚至 MapSetDate 都能正确往返。需要特定格式时传自定义 serializer
  • setValue(null) 会删除 key------「已清除」是一个真实状态,和默认值是两回事。
  • 构建在 useSyncExternalStore 之上:SSR 安全、无需守卫、没有水合不匹配,存储被禁用时退化为内存状态(配合 onError)。
  • 同步是全方位的:跨标签页靠原生 storage 事件(用 listenToStorageChanges 开关),同标签页跨组件靠内部重新广播------始终开启。
  • 同一套 API,不同的生命周期:按标签页隔离用 useSessionStorage,服务端也需要时用 useCookie

@reactuses/core 里把它拿走,让「刷新」不再等于「归零」。

相关推荐
甜味弥漫1 小时前
React Router:从单页应用到前端路由
javascript·node.js
lkshop1 小时前
Next.js 网站 SEO 优化:从 SSR、Sitemap 到结构化数据
开发语言·javascript·ecmascript
析数塔1 小时前
2026前端框架深度解析:React Compiler、Angular Zoneless、Vue 3.5重写游戏规则
前端·vue.js·react.js
程序员Agions1 小时前
ES2026,早该这样了
前端·javascript
weedsfly1 小时前
纯函数与副作用——前端开发中最容易被忽视的质量保证
前端·javascript·面试
杖雍皓1 小时前
没有服务器如何构建现代 Web 应用:平台选型与组合策略
运维·服务器·前端·react.js·github·netlify·vercel
就叫飞六吧1 小时前
F12 控制台日志移植到页面案例
javascript·css·html
sun_weitao1 小时前
微前端框架之qiankun 怎么用?vue3+vite+qiankun 和 react + qiankun
前端·react.js·前端框架
在书中成长1 小时前
HarmonyOS 小游戏《对战五子棋》开发第17篇 - AI的两种评估策略
android·java·javascript