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;
}
  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 組成的元組,預設值是第二個參數。

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 唯一肉眼可見的區別是型別:valueT | 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());

最後那兩個正是手寫版本永遠不會處理的部分:MapSetDate 預設值有各自專屬的序列化器(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 在伺服器端渲染期間絕不觸碰 windowlocalStorage。你的程式碼裡不需要任何 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存在…存活週期跨分頁
useLocalStoragelocalStorage跨重新整理 + 跨瀏覽器重啟✅ 同步
useSessionStoragesessionStorage跨重新整理,按分頁隔離❌ 設計上就按分頁隔離
useCookiecookie由 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 裡把它拿走,讓「重新整理」不再等於「歸零」。