核心原則

State 的儲存層決定它的特性 — 可分享 / 可恢復 / 可導航 的 state 該寫進 URL、不寫進 = silent 把這些特性犧牲掉。

儲存層可分享可 reload 恢復可 back/forward 導航跨 tab 同步跨 device 同步
In-memory
URL部分(同 URL)部分(複製連結)
sessionStorage
localStorage是(同 origin)
Server

寫 stateful UI 時、每個 state 的儲存位置是個設計選擇 — 不選 = 預設用 in-memory = 預設犧牲所有上面五個特性。


為什麼 URL 容易被忽略

URL 是隱形維度

In-memory state 在 React useState / Vue ref / vanilla 變數裡 — 寫起來最便利、是「預設位置」。URL state 需要 URLSearchParams + history.pushState + popstate listener、寫起來成本高。

#67 寫作便利度跟意圖對齊反相關 直接解釋為什麼:URL state 是「對齊使用者期望」的位置(使用者預期 URL 包含 state、能分享)、in-memory 是「便利位置」。預設便利、要刻意才走對齊。

沒寫 URL state 的失敗訊號是 silent

使用者打開搜尋頁、輸入「pagefind」、選擇 title-only filter、看到結果。這時:

  • 複製 URL 分享給朋友 → 朋友打開看到空白搜尋框(query 不在 URL)
  • 重整頁面 → 自己也看到空白搜尋框
  • 點 back → browser back 跳離搜尋頁、不是回到「沒 filter 的同個搜尋」

這三個動作沒有 error、沒有崩潰、就是「state 不見了」。使用者通常以為「網站就這樣」、不會 report bug。Silent 失敗 = 維護者永遠不知道有問題。

對照 #55 Filter × Source 層錯位 — 都是 silent 失敗、都是「該存在的東西不在」。


State 該寫進 URL 的判準

三問

  1. 使用者會分享這個 state 嗎?— 是 → URL(複製連結即帶 state)
  2. 使用者 reload 後預期 state 還在嗎?— 是 → URL 或 sessionStorage
  3. 使用者期望 browser back/forward 在 state 之間導航嗎?— 是 → URL

任一個「是」 → URL。

反向判準:什麼不該寫進 URL

State 類型為什麼不該寫進 URL
Scroll position頻繁變動破壞 history、且每個瀏覽器自己管
Focus / hover stateEphemeral、跟使用者操作直接綁定、寫進 URL 沒意義
Form 編輯中的暫存值使用者沒提交、不該被分享
敏感資訊(token / 密碼)URL 進 history / referer header / log、安全性問題
高頻 polling 結果每秒變、history 爆炸
內部 component state(折疊 / 展開動畫進度)跟 UI 細節綁、不是使用者意圖

多面向:常見 UI 元素的 URL state 對照

面向 1:Search filter(這次任務的 case)

1Query string、scope filter、type filter、tag filter
2→ 都該進 URL:使用者會分享「我搜什麼 + 怎麼篩」

範例 URL:/search/?q=pagefind&scope=title&type=post&tag=js

面向 2:Tab / step navigation

1Active tab、wizard step
2→ 該進 URL:分享 = 直接打開該 tab/step

範例:/settings/?tab=notifications/checkout/?step=payment

面向 3:Sort / pagination

1排序欄位、頁碼
2→ 該進 URL:分享 = 朋友看到同樣排序的同一頁

範例:/posts/?sort=date_desc&page=3

面向 4:Modal / drawer 開合

1看情境:
2- 重要 modal(圖片預覽、編輯對話框)→ URL(可分享 / back 關閉)
3- 純 UX 提示 modal(welcome tour)→ in-memory(不該分享)

面向 5:Theme / UI preference

1Dark mode、字型大小
2→ localStorage(跨 session 但不分享、跟 device 綁)
3不進 URL(不會「分享你的 dark mode 設定」)

URL state 的實作模式

讀:載入時從 URL 同步到 component state

 1function getInitialState() {
 2  const params = new URLSearchParams(location.search);
 3  return {
 4    query: params.get('q') || '',
 5    scope: params.get('scope') || 'all',
 6    type: params.get('type') || null,
 7  };
 8}
 9
10const initialState = getInitialState();
11// component 用 initialState 初始化

寫:state 變動時同步到 URL

 1function syncUrl(state) {
 2  const params = new URLSearchParams();
 3  if (state.query) params.set('q', state.query);
 4  if (state.scope && state.scope !== 'all') params.set('scope', state.scope);
 5  if (state.type) params.set('type', state.type);
 6  const url = `${location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
 7  history.replaceState(null, '', url);
 8}
 9
10// 每次 state 變動觸發
11onStateChange((newState) => syncUrl(newState));

選擇 replaceState vs pushState

  • replaceState:每次 state 變動覆蓋當前 history entry — back/forward 跳過中間狀態
  • pushState:每次 state 變動加新 history entry — back 回到上一個 state

通常 search filter / sort / pagination 用 replaceState(typing 太快、不該每個字符一個 history entry);tab / step 用 pushState(每個 step 該 back 回上一個)。

雙向:聽 popstate 處理 back/forward

1window.addEventListener('popstate', () => {
2  const state = getInitialState();
3  applyStateToUI(state);  // back/forward 後、把 state 套回 UI
4});

沒 listen popstate = back/forward 不會觸發 UI 更新、URL 跟 UI 不同步。


不該套用本原則的情境

「URL 是 state 儲存層」原則在「公開可分享的 UI」成立、但有合理例外:

情境為什麼不該套用
內部 admin 工具不分享、不公開、URL persistence ROI 低
Single-page wizard 強制流程不該允許 deep link 跳關卡(業務規則需要照順序走)
一次性確認對話框不該被 back 回來、不該分享
開發中的 prototype還沒穩定的 UI、不該固化 URL contract

跟其他抽象層原則的關係

原則跟本卡的關係
#44 SSOTURL 是 state 的 SSOT 候選 — 選對位置 = 一處可改、不選 = 多源 drift
#67 寫作便利度跟意圖對齊反相關In-memory state 是便利位置、URL state 是對齊(使用者預期)位置
#55 Filter × Source 層錯位都是 silent 失敗結構 — state 該在的位置不在、使用者沒訊號
#56 視覺完成 ≠ 功能完成URL state 沒做 = 「畫面對了但 reload 後不見」是同類功能缺口
#66 明示語意縮小「URL 不持久化」如果是設計選擇、要明示(「重整會清除狀態」hint)

對應的實作篇

  • 搜尋頁的 scope filter URL persistence — Phase 1+2 修完後 retrospective Checkpoint 1 才發現遺漏(#68 dogfooding)
  • 任何 search / list / dashboard UI — 都該檢視 URL state coverage

判讀徵兆

訊號該做的事
寫互動 UI 但沒寫 URL 同步跑三問、確認該不該寫進 URL
使用者 report「我分享連結給朋友、他看不到我看到的」URL state 缺漏的 silent 訊號顯現
replaceStatepushState 沒區分、所有 state 變動用同一個評估:哪些是 history entry 該被記、哪些不該
沒 listen popstateback/forward 會 silent 失效、補 listener
URL 變超長、含 ephemeral state過度寫進 URL、用反向判準砍掉不該寫的
內心 OS:「state 用 useState 就好、URL 之後再說」「之後再說」= #67 reformer 謊言、補不回來

核心原則:URL 是 stateful UI 的隱形儲存層。沒寫 URL state = silent 犧牲分享 / 恢復 / 導航三個 UX 特性。寫之前跑三問(分享?reload?back/forward?)、任一個是 → URL。