12k
All articles

如何在 React 中使用 Local Storage 持久化状态

使用可复用的 hook 将 React 状态持久化到 localStorage:useState 懒加载初始化、JSON try/catch、SSR 保护和跨标签同步。

OpenReplay Team
OpenReplay Team
如何在 React 中使用 Local Storage 持久化状态

要在 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 安全序列化

简单粗暴的版本只能处理字符串。由于 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 不匹配

服务端没有 windowlocalStorage,因此在首次渲染期间读取存储会在 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 更简洁的原语。useSyncExternalStoreReact 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-tsremoveValue 的即用型 HookinitializeWithValue: 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。

DevTools for the frontend

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

We use cookies to improve your experience. By using our site, you accept cookies.