2026年8月17日
React useSessionStorage Hook:重新整理不丟、只屬於當前分頁的狀態 (2026)
這是一個會在第三步把顧客弄丟的結帳流程:
function Checkout() {
const [step, setStep] = useState(0);
const [form, setForm] = useState<CheckoutForm>(EMPTY_FORM);
// 第 1 步:地址,第 2 步:配送,第 3 步:支付……
}
顧客填好地址、選好配送方式,到支付這一步,支付服務商把他們跳到 3-D Secure 驗證頁再跳回來。或者他們只是按了下重新整理。不管哪種,step 又回到 0,form 又是空的。useState 的壽命就是元件實例的壽命——一次重新整理、一次重新導向、一次整頁導航,它就沒了。
所有人都知道解法是 Web Storage。多數人伸手拿的是 localStorage,它確實管用——直到它管得太多。填了一半的結帳單現在出現在顧客開啟的每一個分頁裡,下週他們回來買別的東西時它還在,而如果他們開了兩個分頁比較配送方案,useLocalStorage 會忠實地把兩張表單互相同步成一樣。你真正想要的,是能在這個分頁的重新整理和重新導向中活下來、分頁關掉就跟著消失的狀態。那就是 sessionStorage,而 @reactuses/core 裡的 useSessionStorage 就是它的 useState 形態。這篇文章講 sessionStorage 真正承諾了什麼(以及沒承諾什麼),什麼時候選它而不是 localStorage 和 cookie,它天生適合的四種模式,以及那些會咬到手寫版本的坑——水合、分頁恢復、window.open。
快速開始
npm install @reactuses/core
import { useSessionStorage } from "@reactuses/core";
function Checkout() {
const [step, setStep] = useSessionStorage("checkout:step", 0);
const [form, setForm] = useSessionStorage<CheckoutForm>("checkout:form", EMPTY_FORM);
return (
<Wizard step={step ?? 0} onNext={() => setStep(s => (s ?? 0) + 1)}>
<AddressStep value={form!.address} onChange={address => setForm(f => ({ ...f!, address }))} />
{/* … */}
</Wizard>
);
}
useSessionStorage(key, defaultValue) 返回和 useState 一樣的 [value, setValue] 元組,支援一樣的函式式更新。值在掛載時從 sessionStorage 讀出,每次更新寫回,型別是 T | null——之所以有 null,是因為 setValue(null) 會刪掉這個 key(下文細說)。重新整理頁面、被跳到支付服務商再跳回來、離開頁面再按瀏覽器後退:step 和 form 都停在顧客離開時的位置。關掉分頁:它們就沒了,這正是目的。
sessionStorage 到底承諾了什麼
這個名字會誤導人以為 “session” 指的是”登入會話”或”瀏覽器會話”。它指的是一個頂層瀏覽上下文——一個分頁或視窗——裡的一個源(origin)。具體來說:
| 事件 | 存活? |
|---|---|
| 重新整理 / 強制重新整理 | ✅ |
| 客戶端路由切換(SPA) | ✅ |
| 整頁導航到同源的另一個頁面 | ✅ |
| 跳轉到第三方網站再回來(OAuth、支付、SSO) | ✅ —— 同一個分頁,回來時同一個源 |
| 瀏覽器後退 / 前進 | ✅ |
| 在新分頁裡開啟同一個 URL | ❌ 全新的空儲存 |
| 關閉分頁 | ❌ 清除(有個例外:能恢復已關閉分頁的瀏覽器也會把它的 sessionStorage 一起恢復) |
| 關閉瀏覽器 | ❌ |
有兩個邊緣情況會讓人意外。第一,window.open() 會複製開啟者的 sessionStorage 到新視窗(按 HTML 規範,只要新視窗保留了 opener),Chrome 的”複製分頁”也會複製——但那是一次性快照,不是即時連結;從那一刻起兩個分頁各走各的。現代瀏覽器預設以 noopener 開啟 target="_blank" 連結,所以普通連結是乾淨起步的。第二,sessionStorage 和同一分頁裡的同源 iframe 是共享的——它們屬於同一個瀏覽上下文組——這也是瀏覽器原生 storage 事件對它唯一有意義的地方(見下文)。
其餘部分和 localStorage 契約相同:同步、只存字串、每個源大約 5 MB、頁面上任何指令碼都能讀——所以它不是安全邊界。它比 localStorage 更短命,洩露時的爆炸半徑更小,但 XSS 讀它一樣輕鬆。任何必須對 JavaScript 保密的東西屬於 httpOnly cookie,不屬於這裡。
useSessionStorage vs useLocalStorage vs useCookie vs useState
按值該住在哪裡、活多久來選:
| 你需要的狀態…… | 用 |
|---|---|
| 和元件活得一樣久 | useState |
| 在這個分頁裡重新整理、重新導向都還在,然後消失 | useSessionStorage |
| 瀏覽器重啟還在,且跨分頁保持同步 | useLocalStorage |
| 伺服器端在第一個請求就需要 | useCookie |
| 是分頁之間的訊息,不是儲存 | useBroadcastChannel |
一條能解決 90% “local 還是 session?“爭論的經驗法則:**如果兩個分頁顯示不同的值算 bug,用 localStorage;如果兩個分頁顯示相同的值算 bug,用 sessionStorage。**主題、語言、“永遠別再顯示”——使用者期望它們在任何地方都是同一個值,所以是 local。填了一半的表單、這個儀表板視圖上的篩選條件、跳去認證之前所在的頁面——它們屬於某一個分頁,所以是 session。
useSessionStorage 和 useLocalStorage 共享完全相同的 API、序列化和內部實現——換個 import,生命週期變了,其他什麼都沒變。useLocalStorage 深度解析裡關於水合、setValue(null)、自定義序列化器和 onError 的一切原樣適用,所以下面我只回顧要緊的部分,把篇幅留給 session 特有的模式和坑。
相比手寫版本你得到了什麼
每個程式碼庫裡都有一個用 useState 初始化函式讀儲存、再用 useEffect 寫回去的版本。下面是那個版本做錯、而 useSessionStorage 做對的地方:
- **SSR 與水合。**這個 hook 建立在
useSyncExternalStore上,伺服器端快照返回預設值。它在伺服器端從不碰window,客戶端第一次渲染與伺服器端 HTML 一致,然後透過正規路徑用儲存裡的值重新渲染——不崩潰、沒有水合不匹配警告、你的程式碼裡不需要typeof window守衛。 - **按預設值型別自動序列化。**傳數字就拿回數字;傳物件就是
JSON.stringify/JSON.parse;傳Map、Set或Date也能正確往返(裸的JSON.stringify(new Map())給你的是{})。需要特定的儲存格式?傳serializer: { read, write }。 setValue(null)刪除 key。“已清除”是一個真實的狀態,區別於”重置為預設值”:setForm(null)之後值是null,下次掛載時又回到EMPTY_FORM。這就是你的”重新開始”按鈕,也是型別是T | null的原因。- **資料損壞不會崩。**有人在 DevTools 裡手改了、舊版本部署寫了另一種結構、
JSON.parse拋了例外——hook 返回預設值並透過onError(預設console.error)回報,而不是把元件帶崩。 - **儲存不可用?降級到記憶體。**某些隱私模式和嵌入上下文存取儲存會拋例外。hook 捕捉它、呼叫
onError,之後的會話裡表現得像普通useState。 - **同一個 key 上的所有元件保持一致。**兩處
useSessionStorage("checkout:step", 0)——頁首的進度條、嚮導主體——每次寫入都一起重渲染。原生storage事件永遠不會在發起修改的那個文件裡觸發,所以手寫版本會漂移;hook 在內部把每次寫入重新廣播一遍,所以漂不了。
模式
多步表單與嚮導
開頭的結帳流程,正確的做法。有兩個細節值得照抄:給 key 加名稱空間(checkout:step、checkout:form),這樣”重新開始”能一起清掉它們,同源上不相干的功能也永遠不會撞 key;把草稿和已提交的內容分開存,這樣下單成功後可以只清草稿、不動別的:
const [step, setStep] = useSessionStorage("checkout:step", 0);
const [draft, setDraft] = useSessionStorage<CheckoutForm>("checkout:form", EMPTY_FORM);
async function submit() {
await api.placeOrder(draft!);
setDraft(null); // 刪掉 key —— 分頁裡什麼都不留
setStep(null);
navigate("/thank-you");
}
對於每個欄位每次按鍵都更新的大表單,儲存寫入是同步的但很便宜(幾 KB 的 JSON);如果你更想批次寫,把欄位更新包進 useDebounceFn,在尾沿寫入草稿。
撐過一次重新導向往返
OAuth、SSO、支付服務商、跳回應用的”驗證你的信箱”連結——任何把分頁帶走再送回來的東西,都需要把”我剛才在哪?“存在一個能撐過整頁卸載、但不該和隔壁分頁共享的地方。這正是 sessionStorage 的主場:像 MSAL 這樣的驗證函式庫預設把 PKCE verifier 和 state 放在這裡,就是這個原因。
function useReturnTo() {
const [returnTo, setReturnTo] = useSessionStorage<string>("auth:returnTo", null);
const navigate = useNavigate();
const stashAndRedirect = () => {
setReturnTo(window.location.pathname + window.location.search);
window.location.assign(buildAuthorizeUrl());
};
const restore = () => {
const target = returnTo ?? "/";
setReturnTo(null); // 用掉它 —— 一次往返,一次恢復
navigate(target, { replace: true });
};
return { stashAndRedirect, restore };
}
兩個分頁、兩次登入、兩個不同的 returnTo——互不串擾。要是用了 localStorage,分頁 B 的重新導向會覆蓋分頁 A 的返回路徑。
絕不能同步的按分頁視圖狀態
讓 useLocalStorage 粉絲措手不及的場景:使用者開了同一個儀表板的兩個分頁,比較”最近 7 天”和”最近 30 天”。用 localStorage 加跨分頁同步,在一個分頁裡改時間範圍,另一個也跟著變,使用者只會覺得這個應用鬧鬼。任何關於這個視窗的視圖狀態——篩選、排序列、展開的行、開啟的是哪個側邊欄——都是 sessionStorage 的值:
const [range, setRange] = useSessionStorage<"7d" | "30d" | "90d">("dashboard:range", "7d");
重新整理保留它,第二個分頁從預設值開始,兩者永不打架。如果你還想要一個跨會話持久的”上次使用”預設值,把它放在 localStorage 裡,讀出來當作 session 的預設值——兩個 hook,兩種生命週期,都寫得明明白白。
每次會話只一次
公告橫幅、“我們使用 cookie”提示、新手引導氣泡——使用者應該能在本次造訪期間把它們關掉,而你不必承諾永遠隱藏:
function ReleaseBanner() {
const [dismissed, setDismissed] = useSessionStorage("banner:v6.5-dismissed", false);
if (dismissed) return null;
return (
<aside>
v6.5 新功能 —— <a href="/changelog">看看改了什麼</a>
<button onClick={() => setDismissed(true)}>關閉</button>
</aside>
);
}
把版本寫進 key(banner:v6.5-dismissed),新版本釋出就有一條新橫幅,不用動舊標記。同樣的形態也適用於”使用者這次會話已經看過開場動畫了”——如果那是本來就該跳過的那種動畫,配上 useReducedMotion。
穩定的按分頁 ID
sessionStorage 是唯一天然給你”每個分頁一個值、重新整理不變”的瀏覽器原語。這正是分頁識別符號想要的——給標記分析事件、關聯日誌,或者讓 useBroadcastChannel 的訊息能按傳送者區分。mountStorageValue 只在首次掛載且 key 不存在時寫入種子值:
const [tabId] = useSessionStorage<string>("tab:id", null, {
mountStorageValue: () => crypto.randomUUID(),
});
// 第一次渲染時是 null,之後是一個在這個分頁的多次重新整理間保持穩定的 UUID
值得知道的坑
- **預設值會在儲存值之前閃一下,只閃一次。**SSR 下伺服器端看不見瀏覽器的儲存,所以首屏顯示預設值,儲存裡的值在水合後的那次渲染才到。對嚮導步驟來說無所謂;對”哪個面板是開啟的”這類東西,你可能想先顯示骨架畫面直到值就位。權衡與
localStorage相同——見 SSR 安全的 React Hooks。 - **“分頁關閉即清除”帶星號。**Chrome、Firefox 和 Safari 在使用者重新開啟已關閉的分頁、或瀏覽器崩潰後恢復會話時,都會把
sessionStorage一起恢復。別把關分頁當作敏感資料的保證清除;必須刪的東西,自己setValue(null)。 - **新分頁 ≠ 同一個分頁。**按住 Ctrl 點你的連結、在新分頁開啟的使用者,帶著空的
sessionStorage到達。這通常是對的(他們想要一個全新的視圖),但意味著”使用者已經關掉橫幅了”和”嚮導在第 3 步”不會帶過去。如果應該帶,那是localStorage的值。 - **
window.open()先複製,再分叉。**如果你window.open()一個同源彈窗(預覽、列印檢視),它以開啟者sessionStorage的一份副本起步。彈窗裡的寫入到不了開啟者;需要的話用useBroadcastChannel或postMessage。 - **
listenToStorageChanges對 sessionStorage 基本沒意義。**原生storage事件只會到達共享同一個儲存區的其他文件——對sessionStorage來說,就是同一分頁裡的同源 iframe,不是別的分頁。同分頁元件之間的同步是另一套始終開啟的機制,不受這個選項影響;除非你有 iframe,否則保持預設、忘了它。 - **不是保險箱。**它是 JavaScript 可讀的儲存。放 PKCE verifier 沒問題(一次性、短命、沒有授權碼就一文不值),放草稿和視圖狀態也沒問題;放一個被竊取會讓你惱火的長期 access token 就放錯了地方。伺服器端會話和
httpOnlycookie 就是為那個存在的。 - **儲存可能滿或被禁。**配額很小,還和同源上的其他一切共用;某些嵌入/隱私上下文存取就拋例外。兩者都透過
onError回報,hook 繼續在記憶體裡工作。記下日誌——一條”我的表單重置了”的 bug 回報,追到最後往往是一個沒人看的QuotaExceededError。 - 值是
T | null,這是故意的。setValue(null)之後 key 沒了,你拿到null,不是預設值。如果你的程式碼處理不了null,要麼永遠不調setValue(null)(改成寫預設值),要麼在讀的地方正規化:const s = step ?? 0。
什麼時候不該用 useSessionStorage
- 值應該在任何地方、永遠都是同一個(主題、語言、“永不再顯示”)→
useLocalStorage。 - 伺服器端在第一個請求就需要它(無閃爍的主題、A/B 分桶、認證會話)→
useCookie。 - 分頁之間需要對話,不是儲存(“你在另一個分頁裡登出了”)→
useBroadcastChannel。 - 你要跨渲染保留一個值,不是跨重新整理 →
useState、useRef或useLatest——本系列上一篇講了各自什麼時候用。 - 你想把它放進 URL(可分享的篩選、可深層連結的步驟)→ 放進查詢字串;當一個連結應該復現視圖時,它勝過所有儲存 API。
要點
sessionStorage= 一個分頁、一個源、直到分頁關閉。它能撐過重新整理、SPA 與整頁導航、後退/前進、重新導向往返;它不會跨進新分頁(除了透過window.open()/ 複製分頁的一次性複製),而且瀏覽器在重新開啟已關閉分頁時可能恢復它。useSessionStorage(key, default)是帶著這種生命週期的即插即用useState:同樣的元組、函式式更新、物件/Map/Set/Date 自動序列化、setValue(null)刪除、onError處理損壞資料和被禁的儲存、透過useSyncExternalStore做到 SSR 安全,同一個 key 上的所有元件保持同步。- 經驗法則:兩個分頁不一致算 bug →
localStorage;兩個分頁一致算 bug →sessionStorage。多步表單、重新導向往返、按分頁的視圖狀態、每會話一次的標記、按分頁的 ID 都是 session 值。 - 它是一種生命週期,不是安全邊界。秘密放
httpOnlycookie,敏感 key 自己用setValue(null)清,別信任關分頁。
useSessionStorage、useLocalStorage、useCookie 以及另外 110+ 個 SSR 安全、TypeScript 優先的 hook 都在 @reactuses/core 裡——一次安裝,可 tree-shake,零依賴負擔。
npm install @reactuses/core