昨天我們從 useLocalStorage 這個 Hook,來看 react-use、usehooks-ts 與 @react-hookz/web 的實作概念,而它們分別給出了不同的答案。
三種答案都能動,那差別到底在哪裡?
不過,我們昨天只看了一個地方:寫入之後,另一個元件怎麼知道。
而無論是這三個函式庫,還是我們一開始自己寫的版本,都站在同一個前提上:頁面第一次渲染時,就可以直接存取 localStorage。
這對在瀏覽器才產生畫面的單頁應用程式 (Single Page Application, SPA) 來說很正常,但如果這個頁面現在必須先在伺服器上渲染過一次呢?
讓我們把 Day 16 的 App 原封不動地交給伺服器先渲染一次,看看會發生什麼。
伺服器端渲染 (server-side rendering, SSR) 是先在伺服器上執行一次元件,把結果變成 HTML 送到瀏覽器。Next.js 這類框架預設就會這樣做,不必另外設定。
如果不透過框架,直接呼叫 React 提供的 renderToString,做的是同一件事:
import { renderToString } from "react-dom/server";
import { App } from "./App";
const html = renderToString(<App />);
執行之後,伺服器丟出了這樣的錯誤:
ReferenceError: localStorage is not defined
伺服器上沒有瀏覽器,也就沒有 localStorage。由於 useState 的初始值函式在伺服器上照樣會執行,所以它一讀 localStorage 就丟錯。如果 Hook 裡寫的是 window.localStorage,訊息就會變成另一句更常見的錯誤:window is not defined。
既然如此,我們可以先判斷有沒有 window,它的值是 undefined 的話就不讀。我們在模組載入時判斷一次,伺服器上直接用初始值:
import { useState } from "react";
const isServer = typeof window === "undefined";
export function useLocalStorage(
key: string,
initialValue: string,
): [string, (value: string) => void] {
const [value, setValue] = useState(() =>
isServer ? initialValue : (localStorage.getItem(key) ?? initialValue),
);
const set = (next: string): void => {
localStorage.setItem(key, next);
setValue(next);
};
return [value, set];
}
伺服器不再丟錯,負責切換主題的 ThemeSwitch 與負責顯示主題的 ThemeLabel,都以 light 渲染成 HTML。目前,問題似乎解決了。
但是,假使使用者上次已經把主題切成 dark,再重新整理頁面,我們會在主控台看到這樣的錯誤:
Hydration failed because the server rendered text didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
- A server/client branch `if (typeof window !== 'undefined')`.
這是使用 SSR 時常見的水合錯誤 (hydration error)。
瀏覽器收到伺服器送來的 HTML 之後,React 會在瀏覽器裡把元件再執行一次,接手那份已經存在的 HTML,這一步叫做水合。水合時,React 預期第一次渲染的結果跟伺服器產生的 HTML 一模一樣。如果不一樣,就會報告這個錯誤。
伺服器上沒有 localStorage,所以渲染出來的是 light;瀏覽器裡的第一次渲染讀到了 dark。兩邊對不上,React 只好丟掉伺服器的那一份,在瀏覽器裡重新產生這棵元件樹。
錯誤訊息底下還列了幾種常見的原因,第一條就是我們剛剛加上的那種判斷。
那麼,如果第一次渲染先不讀,等掛載之後再讀呢?
import { useEffect, useState } from "react";
export function useLocalStorage(
key: string,
initialValue: string,
): [string, (value: string) => void] {
const [value, setValue] = useState(initialValue);
useEffect(() => {
setValue(localStorage.getItem(key) ?? initialValue);
}, [key, initialValue]);
const set = (next: string): void => {
localStorage.setItem(key, next);
setValue(next);
};
return [value, set];
}
這次伺服器與瀏覽器的第一次渲染都是 light,水合不再出錯。useEffect 執行之後,畫面才換成 dark。
可是,這個代價不只落在先經過伺服器端渲染的頁面上。一個從頭到尾只在瀏覽器裡執行的應用程式,原本第一次渲染就讀得到 dark,現在也得先渲染一次 light,畫面可能會閃一下。
兩種寫法,各自對一種頁面是對的:第一次渲染就讀,適合只在瀏覽器裡執行的頁面;掛載之後才讀,適合先在伺服器上渲染過的頁面。
那麼,Hook 能不能自己判斷,現在是哪一種頁面?
在 useState 的初始值函式裡,Hook 看得到的是現在有沒有 window。可是水合的時候,window 已經在了。這一次渲染是在接手伺服器產生的 HTML,還是一般的第一次渲染,從這裡分辨不出來。
這個頁面有沒有先在伺服器上渲染過,知道的是呼叫端。
所以,這不是伺服器上少了 window 的問題,而是有一件事,Hook 自己看不到,呼叫端卻知道。
那麼,把 localStorage 包成 Hook 之後,呼叫端還需要知道什麼?
讓我們回到 react-use、usehooks-ts 與 @react-hookz/web 這三個函式庫,看看它們怎麼面對伺服器端渲染,以及另一件包裝時容易被略過的事:存進去的東西讀不回來的時候。
前面兩次嘗試,其實拆出了兩件事:伺服器上不能丟錯,以及水合前後要一致。三份實作同樣用簡化過的版本來看。
react-use 在模組載入時判斷一次 isBrowser。不在瀏覽器裡,就直接把初始值交回去:
const isBrowser = typeof window !== "undefined";
export function useLocalStorage<T>(
key: string,
initialValue?: T,
): [T | undefined, SetValue<T>, () => void] {
if (!isBrowser) {
return [initialValue, noop, noop];
}
const [state, setState] = useState(() => initializer.current(key));
// ⋯⋯
}
initializer 是讀取 localStorage 的那個函式,後面還會再看到它。
這裡的 return 發生在任何 Hook 呼叫之前。按照 Hook 規則的一般寫法,這種條件式提早返回會讓 Hook 呼叫順序不穩定。
react-use 則刻意利用 isBrowser 在同一個執行環境裡不會變這件事,在伺服器,提早返回每次都會成立;而在瀏覽器,條件則每次都不會通過。因此,這裡的呼叫順序其實是固定的。
伺服器上不丟錯了。可是到了瀏覽器,第一次渲染一樣在 useState 的初始值函式裡讀 localStorage,跟我們加上 isServer 判斷的版本相同,水合時會對不上。
其實,react-use 在這之後還會用 useLayoutEffect,在掛載與 key 改變時重新讀一次 localStorage。但那發生在第一次渲染之後,並不能讓伺服器渲染與瀏覽器水合的第一次結果一致。
usehooks-ts 也在模組載入時判斷 IS_SERVER,伺服器上直接回傳初始值。不同的是,第一次渲染要不要讀 localStorage,它沒有替呼叫端決定:
const [storedValue, setStoredValue] = useState(() =>
initializeWithValue ? readValue() : resolveInitial(),
);
useEffect(() => {
setStoredValue(readValue());
}, [key]);
前面的兩種寫法都在這裡。initializeWithValue 是 true,第一次渲染就讀;是 false,就先用初始值(resolveInitial),等掛載之後的 useEffect 再讀。
在原始碼 useLocalStorage.ts 裡,這個選項的型別是這一行:
initializeWithValue?: boolean
它的預設值是 true。這一行上方的註解寫著,在伺服器端渲染的情境下,應該把它設成 false。
usehooks-ts 在 Hook 裡面判斷伺服器,@react-hookz/web 則在 Hook 外面就判斷完了。它的 useLocalStorageValue 在模組載入時,先探測 localStorage 能不能用:
let IS_LOCAL_STORAGE_AVAILABLE: boolean;
try {
IS_LOCAL_STORAGE_AVAILABLE = isBrowser && Boolean(globalThis.localStorage);
} catch {
IS_LOCAL_STORAGE_AVAILABLE = false;
}
export const useLocalStorageValue = IS_LOCAL_STORAGE_AVAILABLE
? (key, options) => useStorageValue(localStorage, key, options)
: () => ({ value: undefined, set: noop, remove: noop, fetch: noop });
能用的話,useLocalStorageValue 就是把 localStorage 交給 useStorageValue 的那個函式;不能用的話,它整個換成一個什麼都不做的版本,value 是 undefined。伺服器上走的是後者,存取 localStorage 會丟錯的瀏覽器環境也是。
至於水合,useStorageValue 也有 initializeWithValue 選項,預設同樣是 true。設成 false 時,第一次渲染的 value 是 undefined,掛載之後才讀,正好對得上伺服器上那個什麼都不做的版本。
三份的寫法看完了,接著讓它們實際跑一次。pnpm test 會讓三份臨摹跑同一組情境,印出一張紅綠表。✓ 表示這份實作替呼叫端承擔了這件事,✗ 表示沒有。
跟伺服器有關的情境,設定都是:伺服器上沒有 localStorage,瀏覽器的 localStorage 裡已經存了值,就像重新整理一個用過的頁面。印出來是這三列:
| 情境 | react-use | usehooks-ts | @react-hookz/web |
|---|---|---|---|
| 伺服器端渲染不丟錯 | ✓ | ✓ | ✓ |
| 預設選項下,水合前後一致 | ✗ | ✗ | ✗ |
呼叫端關掉初次讀取(initializeWithValue: false)後,水合前後一致 |
✗ | ✓ | ✓ |
第一列三個實作都是綠的。window 不存在這件事,三個函式庫都替呼叫端擋下來了。
第二列則相反,三個實作都是紅的。因為預設選項下,三份的第一次渲染都會去讀 localStorage,所以跟我們加上 isServer 判斷的那個版本一樣,水合時對不上。
第三列才分開:usehooks-ts 與 @react-hookz/web 留了一個開關,react-use 沒有。
三個函式庫,沒有一個替呼叫端決定水合該怎麼辦。
這與前面看到的事情是一致的:這個頁面有沒有先在伺服器上渲染過,在 useState 的初始值函式裡看不出來。
我們在 Day 02 那篇文章提過,介面寬度算的不只是參數個數。initializeWithValue 讓介面多了一格,而這一格放的,正是那件 Hook 看不到、呼叫端才知道的事。
react-use 的介面上沒有這一格。那件事並沒有因此消失,只是呼叫端得在 Hook 外面自己處理。
伺服器那件事,Hook 自己看不到,所以交回給了呼叫端。接下來這件事,Hook 自己看得到:存在 localStorage 裡的字串,解析失敗了。
這不需要刻意製造。昨天 useLocalStorage Hook 的例子,為保持簡單,存進去的是字串 dark,沒有經過 JSON.stringify。但其實三個函式庫預設都是用 JSON 存取,假使它們讀到的是純字串的 dark,JSON.parse 就會丟出語法錯誤:
SyntaxError: Unexpected token 'd', "dark" is not valid JSON
如此,畫面上就會顯示預設的 light。使用者上次選的 dark 不見了,localStorage 裡存的 dark 卻還在。
結果一樣,那差別在哪裡呢?
catch這是前面提到的 initializer:
const initializer = useRef((key: string): T | undefined => {
try {
const raw = localStorage.getItem(key);
if (raw !== null) return JSON.parse(raw) as T;
if (initialValue) localStorage.setItem(key, JSON.stringify(initialValue));
return initialValue;
} catch {
return initialValue;
}
});
try 包住的不只有 JSON.parse。原始碼在這個 catch 裡的註解寫著,在隱私模式或有儲存限制的時候,localStorage 本身也會丟錯,JSON.parse 與 JSON.stringify 也會。這些情況全都落進同一個 catch,交回初始值,什麼都沒有留下。
所以從呼叫端看過去,「從來沒存過」、「存了但讀不懂」與「localStorage 不能用」,拿到的都是同一個初始值。
另外兩份同樣退回初始值,但會在主控台留下一行。usehooks-ts 的 deserialize 是這樣:
const deserialize = (raw: string): T => {
if (options.deserializer) return options.deserializer(raw);
if (raw === "undefined") return undefined as T;
try {
return JSON.parse(raw) as T;
} catch (error) {
console.error("Error parsing JSON:", error);
return resolveInitial();
}
};
@react-hookz/web 的 defaultParse 是這樣:
const defaultParse = <T>(raw: string | null, fallback: T | null): T | null => {
if (raw === null) return fallback;
try {
return JSON.parse(raw) as T;
} catch (error) {
console.warn(error);
return fallback;
}
};
兩段做的事情很接近:JSON.parse 失敗,印一行,交回某個值。差別在那個值從哪裡來。
usehooks-ts 的 deserialize 只收一個字串,失敗時交回的初始值,是從 Hook 裡面拿的。「沒有這個 key」也不經過它:readValue 讀到空的,就直接用初始值,不呼叫 deserialize。
@react-hookz/web 的 defaultParse 則多收一個 fallback。「沒有這個 key」(raw 是 null)與「讀不懂」走的是同一個函式,交回的都是傳進來的那個值。
這個差別,在呼叫端想換掉預設的解析方式時才會浮現。兩個函式庫留給呼叫端的選項,型別分別是:
// usehooks-ts
deserializer?: (value: string) => T;
// @react-hookz/web
parse?: (raw: string | null, fallback: T | null) => T | null;
@react-hookz/web 的 parse 一拿到參數就知道兩件事:raw 可能是 null,而讀不懂的時候,有一個 fallback 可以交回去。這個函式要負責什麼,簽名上已經寫出來了。
usehooks-ts 的 deserializer 只收一個字串。而且一旦傳了它,deserialize 的第一行就把整件事交出去,後面對 "undefined" 的特別處理與 console.error 都不會執行(它丟出的錯,仍然會被 readValue 外層的 try 接住)。要知道自己換掉了哪些東西,得回去讀 Hook 的本體。
印出來的紅綠表裡,跟解析失敗有關的是這兩列:
| 情境 | react-use | usehooks-ts | @react-hookz/web |
|---|---|---|---|
| 存的不是合法 JSON:退回初始值 | ✓ | ✓ | ✓ |
存的不是合法 JSON:留下痕跡(console) |
✗ | ✓ | ✓ |
「留下痕跡」看的只是主控台。三份都沒有把解析失敗交回給呼叫端,呼叫端從 Hook 拿到的,仍然只是一個值。
把伺服器與解析失敗放在一起,三個函式庫的呼叫端,各自還需要知道這些事:
localStorage 不能用。initializeWithValue 設成 false;換掉 deserializer 時,要知道自己換掉了哪些處理。initializeWithValue;換掉 parse 時要負責的事,寫在它的簽名上。三份都把「這個頁面有沒有先在伺服器上渲染過」留給了呼叫端。在它們的寫法裡,那是 Hook 看不到的事。
除此之外,三個函式庫對讀取失敗的處理也不一樣:react-use 直接吞掉,usehooks-ts 留下 console.error,@react-hookz/web 留下 console.warn。後兩者都讓呼叫端換掉解析方式,而 @react-hookz/web 還多交出一個 fallback。
介面多一個選項,不只是多一個參數。它代表模組願意把哪一個決策交給誰。
Day 16 看的是寫入之後誰會知道,今天看的是伺服器上與讀不回來的時候。到目前為止,我們看到的是:Hook 替呼叫端承擔得越多,呼叫端知道的事情可能越少;但它同時也替呼叫端做了更多決定。
那麼,模組做得更深,真的只是把複雜度藏起來嗎?
還是它其實也把某些決定一起藏掉了?
深一點,總是比較好嗎?
💡 文中三個函式庫的片段都是節錄,省略了錯誤處理與型別細節,也不是 production-ready 的實作;我們自己的兩個修法版本也只是為了把兩種讀取時機並排。
本文對照
usehooks-ts3.1.1、react-use17.6.1、@react-hookz/web26.1.0,查核於 2026-08-31;文中引用的原始碼行號與註解查核於 2026-10-02。錯誤訊息與水合的行為跑在react19.3.0、react-dom19.3.0 與 Node.js 24.12.0,查核於 2026-10-02。