FE Day 20 Client Components 與互動設計
執行需求:CPU 可跑。今天是「前端開發實戰:React 與 Next.js 全套」系列的第二十篇。昨天 Day 19 我們把 Server Component 的資料取得與快取策略講完,今天轉向另一個極端:Client Component。前兩天的設計原則是「預設 Server、需要互動才 Client」;今天則是把 Client Component 的邊界、hydration 的工作原理、React 19 新提供的幾個 Hook(useOptimistic、useTransition、useFormStatus)一次講清楚。我們會在 booking-fe 上把「取消預約」的按鈕寫成完整的 Client Component,並展示「樂觀更新 + 失敗回滾」的標準模式。讀完之後你應該能在 30 秒內判斷一段互動邏輯「該用 Server 還是 Client」、能正確處理 hydration 不匹配、能把樂觀更新寫得沒有 bug。
引言
寫後端的人第一次碰到 React hydration 常會困惑:「為什麼同一段 JSX 在伺服器跑一次、瀏覽器又跑一次?」這是 SSR(Server-Side Rendering)的成本,也是 Server Component 預設的副作用。理解 hydration 的運作方式,能幫你寫出正確的 Client Component,避免「畫面閃爍」、「互動沒反應」、「出現 hydration warning」三類常見 bug。
今天的重點有四個。第一,什麼是 hydration、為什麼需要它;第二,"use client" 的精準放置(昨天有提,今天展開);第三,常見的互動模式:表單輸入、開合選單、modal、tab、tooltip——它們各自怎麼選 Client 或 Server;第四,React 19 的新 API:useOptimistic、useTransition、useFormStatus 如何把「樂觀更新」「非阻塞 UI」「表單送出狀態」這幾件事變成框架級別的標準做法。
對照後端經驗,hydration 像是「樣板引擎把 HTML 給瀏覽器之後,瀏覽器再接手把事件監聽掛上去」。如果樣板(伺服器 render 結果)跟瀏覽器端的 React tree 不一致,hydration 就會失敗。修法的核心是「Client Component 不能依賴瀏覽器獨有的 API(例如 window、localStorage)做初始 render」。這條規則會在「常見錯誤」段落展開。
什麼是 hydration
Server Component 在伺服器上跑完之後,Next.js 把結果轉成 HTML 與 RSC payload 送到瀏覽器。HTML 立刻被解析、畫面先出現(FCP,First Contentful Paint);RSC payload 是 React tree 的序列化描述,用來告訴瀏覽器端的 React「這棵樹長什麼樣」。當 React 在瀏覽器啟動時,會把 RSC payload 還原成 virtual DOM tree,並把現有的 HTML 與之比對——這個過程叫做 hydration。
hydration 期間 React 做了三件事:第一,比對 HTML 與 virtual DOM,確認兩者一致;第二,給所有 Client Component 的 DOM 節點掛上事件監聽(onClick、onChange 等);第三,準備好 useState、useReducer、useEffect 等 client-only Hook。完成之後,瀏覽器才能對使用者的互動(點按鈕、輸入文字)做出反應。
hydration 是「必要成本」:伺服器先畫一次、瀏覽器再接手。如果互動失敗,最常見的原因是「hydration mismatch」——伺服器 render 出來的 HTML 跟 Client Component 第一次 render 出來的 virtual DOM 不一致。常見的觸發情境:
- 在 render 函式裡用
Date.now()、Math.random(),伺服器跟瀏覽器跑出不同結果。 - 直接讀
window、document、localStorage,伺服器端沒有這些物件。 - 根據
navigator.userAgent判斷瀏覽器類型,伺服器端的 UA 跟瀏覽器不同。 - 格式化日期用
toLocaleString()但時區不同。
這些情境的修法都一樣:把 client-only 的邏輯搬進 useEffect(只在瀏覽器端跑)、或用 "use client" 隔離範圍。具體寫法在「常見錯誤」段落會示範。
"use client" 的精準放置策略
昨天已經提過「"use client" 放在葉節點」,今天從另一個角度講:把這個宣告理解為「從這裡開始,後面的所有 import 都被視為 client module」。一個完整的目錄結構可能是這樣:
src/features/booking/components/
├── BookingCard.tsx # Server Component(純展示)
├── CancelBookingButton.tsx # Client Component(有 useState + onClick)
└── CancelBookingDialog.tsx # Client Component(modal 互動)
實務上的判斷法:
- 元件只用 props 接收資料、沒有 useState / useEffect / onClick → 保持 Server。
- 元件需要 useState 管理內部狀態(輸入框、開合、計數)→ 標 client。
- 元件需要 onClick / onChange / onSubmit 等事件 → 標 client。
- 元件需要 useEffect 訂閱瀏覽器 API(IntersectionObserver、localStorage)→ 標 client。
- 元件需要 React 19 的 useOptimistic / useFormStatus / useFormState → 標 client。
「葉節點」這個原則的額外好處是「動態載入」。如果整個 BookingCard 都是 client,那它底下任何一個小互動(例如 hover 效果)都會把整個檔案拉進 client bundle;如果 CancelBookingButton 獨立成 client 檔,瀏覽器只在「使用者真的按下按鈕」時才需要載入它,預設可以走 dynamic import 與 code splitting。
完整實作:取消預約的樂觀更新
「按下取消預約的按鈕」是最典型的 Client Component 場景:需要立即回饋、需要處理請求中的狀態、需要失敗時回滾。我們把這個互動寫成完整的範例:
// src/features/booking/components/CancelBookingButton.tsx
// Client Component:樂觀更新 + 失敗回滾
"use client";
import { useOptimistic, useTransition, useState } from "react";
import { cancelBooking } from "../api";
type Booking = { id: string; status: "active" | "cancelled" };
export function CancelBookingButton({ booking }: { booking: Booking }) {
const [optimistic, setOptimistic] = useOptimistic(booking);
const [, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function handleClick() {
setError(null);
startTransition(async () => {
setOptimistic({ ...booking, status: "cancelled" }); // 樂觀更新
try {
await cancelBooking(booking.id);
} catch (e) {
setOptimistic(booking); // 失敗回滾
setError(e instanceof Error ? e.message : "取消失敗");
}
});
}
return (
<div>
<button
type="button"
onClick={handleClick}
disabled={optimistic.status === "cancelled"}
className="rounded bg-red-700 px-3 py-1 text-white disabled:bg-slate-300"
>
{optimistic.status === "cancelled" ? "已取消" : "取消預約"}
</button>
{error && <p role="alert" className="mt-1 text-sm text-red-700">{error}</p>}
</div>
);
}
這段程式用了 React 19 的三個新 API,每個都解決一個具體問題。useOptimistic 讓我們「假裝操作已經成功」——把 booking 的 status 立刻改成 cancelled、按鈕立刻變成「已取消」、不用等伺服器回應。useTransition 告訴 React「這次更新可以中斷、不影響其他 UI 響應」——按鈕按下後即使伺服器慢,使用者也能繼續捲動頁面、點別的東西。useState 處理「真的失敗了要顯示什麼訊息」——把錯誤訊息綁在 role="alert" 上,螢幕閱讀器會自動朗讀。
注意 useOptimistic 的運作模型:「樂觀狀態」是基於「實際狀態」產生的暫時值。當 transition 結束(成功或失敗),useOptimistic 會自動回到實際狀態;如果我們在 transition 內呼叫了 setOptimistic(...),那這個暫時值會一直存在到 transition 完成。所以上面的寫法是「樂觀更新 → 伺服器回應成功 → 自動回到實際狀態(已取消)」,失敗時則在 catch 裡呼叫 setOptimistic(booking) 強制回滾。
API 函式 cancelBooking 怎麼寫?實務上常見的兩種做法:
// src/features/booking/api.ts
// 業務專屬 API:可以用 fetch,也可以包成 Server Action(Day 21)
export async function cancelBooking(id: string): Promise<void> {
const res = await fetch(`/api/bookings/${id}/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
throw new Error(`取消失敗(${res.status})`);
}
}
Day 24 會把 /api/bookings/[id]/cancel 寫成 Route Handler。在那之前,這個 fetch 可以指到 FastAPI 後端(Web 系列的 POST /bookings/{id}/cancel)或 mock server,重點是「前端呼叫端」與「後端實作」完全解耦。
常見的 Client 互動模式
寫一段時間的 Client Component 之後,你會發現大部分互動可以歸納成幾個模式:
輸入受控:表單欄位的值需要即時驗證、轉換、自動儲存,這種用 useState 控制 value、onChange 更新 state。Day 12 已經詳細展開過。
開合切換:手風琴、下拉選單、modal、popover,這類「點一下出現、點外面消失」的 UI 用 useState 追蹤開合狀態,搭配 useEffect 監聽 click outside。
資料訂閱:「使用者開著頁面 30 秒、想看到最新資料」,這種用 useEffect + setInterval 定時 revalidate,或用 SWR / TanStack Query 之類的快取函式庫(Day 14 已經提過)。
瀏覽器 API:需要讀 localStorage、IntersectionObserver、navigator.clipboard,這些一定 client,且要在 useEffect 內呼叫(不能在 render 函式直接用)。
樂觀互動:按讚、追蹤、勾選完成——這些使用者期待「立刻有反應」的動作用 useOptimistic。
把這五個模式放在一起比較,「輸入受控」「開合切換」「瀏覽器 API」是 React 17/18 時代就有的標準寫法;「資料訂閱」通常會外包給 SWR / React Query;「樂觀互動」是 React 19 之後才有的官方 API,雖然 React 18 也可以用 useState 模擬,但 useOptimistic 寫起來更乾淨、不容易漏掉失敗回滾。
React 19 的三個新 Hook 總覽
除了 useOptimistic,React 19 還提供了兩個跟互動設計高度相關的 Hook。
useTransition:把更新標記為「非緊急」,React 會延後 render、不卡 UI。常用於「點按鈕切換分頁內容」、「按搜尋刷新清單」這類使用者期待「立即回饋但不在意結果延遲」的互動。
// useTransition:非阻塞狀態切換
import { useTransition, useState } from "react";
function TabSwitcher() {
const [tab, setTab] = useState("list");
const [pending, startTransition] = useTransition();
function switchTab(next: string) {
startTransition(() => {
setTab(next); // 即使 next 渲染很慢,UI 也不卡
});
}
return (
<div>
<button onClick={() => switchTab("list")} aria-pressed={tab === "list"}>清單</button>
<button onClick={() => switchTab("chart")} aria-pressed={tab === "chart"}>圖表</button>
<div aria-busy={pending}>{pending ? <p>切換中…</p> : <Content tab={tab} />}</div>
</div>
);
}
useFormStatus:搭配 <form action={...}>(Server Action 或 form submission),可以讀到「表單是否正在送出」這類資訊。這個 Hook 一定要在 form 內的元件使用,且 form 必須是 <form> 元素:
// src/features/booking/components/SubmitButton.tsx
// useFormStatus:把送出狀態綁在按鈕上
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="rounded bg-slate-900 px-3 py-1 text-white disabled:bg-slate-400"
>
{pending ? "送出中…" : children}
</button>
);
}
這個寫法的好處是把「送出中」狀態跟按鈕綁在一起,不需要父元件傳 pending prop、按鈕自己讀 form 狀態。在大型表單裡這模式特別好用,因為送出按鈕通常被設計成 form 內最深處的元件,從頂層傳 prop 會很囉嗦。
第三個 Hook useFormState(有時寫成 useActionState,React 19 後者取代前者)會在 Day 21 表單與 Server Actions 一起展開。
無障礙設計:鍵盤操作與 ARIA 屬性
Client Component 因為有互動,幾乎一定要考慮鍵盤操作與螢幕閱讀器相容性。本系列 Day 28 會把 a11y 完整展開,這裡先給幾個常見的 client 互動模式需要的屬性,作為你現在寫按鈕、開合選單、modal 時的基本功。
鍵盤:所有「用滑鼠點得到」的東西,鍵盤也要可以 focus 與啟動。原生 <button>、<a>、<input> 內建支援;如果用 <div onClick> 自製「按鈕」,就要手動加 tabIndex={0}、role="button"、onKeyDown 處理 Enter 與 Space 鍵。實務上不要自製按鈕,直接用 <button> 即可。
Focus 管理:Modal 開啟時,焦點要移到 modal 內第一個可 focus 的元素;modal 關閉時,焦點要回到當初觸發的那顆按鈕。React 19 沒有內建 focus trap,但可以用 useRef + useEffect 自己做。社群有 focus-trap-react 套件,Next.js 15 也能直接用。
ARIA 屬性:aria-busy 標載入中、aria-pressed 標切換鈕狀態、aria-current 標當前頁面、aria-expanded 標開合選單、role="alert" 標錯誤訊息。這些都是 HTML 原生屬性、不需要 client 也能用,但在 client 互動的元件上特別需要。
// src/features/booking/components/BookingMenu.tsx
// Client Component:開合選單 + ARIA 屬性
"use client";
import { useState, useRef, useEffect } from "react";
export function BookingMenu({ bookingId }: { bookingId: string }) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLUListElement>(null);
// 點外面自動關閉
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (!menuRef.current?.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
return (
<div className="relative">
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="rounded border px-3 py-1"
>
操作
</button>
{open && (
<ul ref={menuRef} role="menu" className="absolute right-0 mt-1 rounded border bg-white shadow">
<li role="menuitem">
<button type="button" onClick={() => console.log("edit")}>編輯</button>
</li>
<li role="menuitem">
<button type="button" onClick={() => console.log("cancel")}>取消</button>
</li>
</ul>
)}
</div>
);
}
這段範例展示了開合選單的標準寫法:aria-haspopup="menu" 告訴螢幕閱讀器「這顆按鈕會打開 menu」、aria-expanded 反映當前開合狀態、useEffect 處理 click outside、useRef 拿到 menu DOM 節點。視障使用者用 NVDA / VoiceOver 測試時,會聽到「操作按鈕,已折疊/已展開,menu」這樣的朗讀。
DevTools 觀察 Client Boundary
想在瀏覽器上看到「哪些元件是 client、哪些是 server」,最直接的方式是裝 React DevTools。打開 Components 面板,每個元件會有一個標籤告訴你它的類型:Server Component 沒有特殊標籤;標 "use client" 的元件會顯示為一般的 client 元件;layout 則會標 Server 或 Client 圖示(Next.js 版的 DevTools 會標)。
另一個驗證方式是看 bundle。在 pnpm build 之後,打開 .next/static/chunks/ 資料夾,會看到一個個 JS chunk。每個檔名包含 [component-name] 的 chunk 通常對應一個 client 模組——把檔名 grep 出來,就能知道「到底有哪些 client 元件被打包進瀏覽器」。如果發現「這顆按鈕明明只需要 useState、卻打包了 200KB」就是 client boundary 畫得不對,要回去把不必要的 import 從 client 端移除。
最後是 Network 面板的觀察。打開 DevTools 的 Network 面板並篩選 RSC 請求,會看到每次換頁都有一個 RSC round-trip。response 是 React tree 的差量描述(JSON 格式),瀏覽器解析後做對應的 DOM 更新。把 response 展開看 payload,能直接驗證「這次更新到底傳了哪些元件的 props」。這個觀察對「為什麼這個 server component 的 prop 沒更新」這類除錯很有用。
小結
常見錯誤與踩雷
第一個雷是「useEffect 內讀 localStorage、但 state 初始化在 render 函式內」。這會造成 hydration mismatch:伺服器端沒有 localStorage,所以初始 state 是 null;瀏覽器端 hydration 時讀 localStorage、拿到真實值;React 比對兩者發現不一致、噴出 warning。修法是把讀取動作搬到 useEffect:
// 修正前(hydration mismatch)
function Counter() {
const [count, setCount] = useState(localStorage.getItem("count") || "0"); // 壞
// 伺服器沒有 localStorage,count 是 null;瀏覽器拿到的可能是 "5"
}
// 修正後
function Counter() {
const [count, setCount] = useState("0");
useEffect(() => {
const saved = localStorage.getItem("count");
if (saved) setCount(saved);
}, []);
// 第一次 render 跟伺服器一致(都是 "0"),effect 內再讀 localStorage
}
第二個雷是「在 render 函式裡用 Date.now() 做 SSR cache busting」。伺服器 render 時算一次、瀏覽器 hydrate 時又算一次,兩者時間不同 → 整棵 tree mismatch。修法跟上面一樣:搬到 useEffect,或用 suppressHydrationWarning(少用,且只對單一節點有效)。
第三個雷是「Client Component 太大、把整個 feature 拉進 client bundle」。例如把 CancelBookingButton 跟它同一檔的所有 helper(包含 cancelBooking 函式)都包在 client 檔,結果 cancelBooking 內呼叫的 API 網址也跟著外洩。修法是把 cancelBooking 拆成獨立的 API 函式(放 features/booking/api.ts),按鈕只負責呼叫、不直接知道 API 怎麼實作。
第四個雷是「useOptimistic 忘了在失敗時回滾」。React 19 會在 transition 結束時自動回到實際狀態,但如果「失敗」是用 throw 表達(不是 rejected promise),React 不會自動處理。寫法上的保護措施:把 API 呼叫包在 try/catch、catch 內 setOptimistic(actualValue) 強制回滾,並用 setError(...) 把訊息傳給使用者。
第五個雷是「useTransition 包了同步的 setState、但又 await 了 async」。React 對 startTransition(async () => { ... }) 的支援是「transition 期間的 await 都視為 transition」,但 await 之後的程式碼可能不在 transition 內。寫法上的保險:把 await 的結果處理也包在 transition callback 內,或明確呼叫 await startTransition(async () => { ... })。這個雷比較細節,遇到再查官方說明即可。
效能與實務提醒
Client Component 對 bundle 的影響是「每個檔案都會進入 client bundle,直到沒人 import 它」。要降低這個成本,常見做法是用 next/dynamic 把非首屏的元件動態載入:
// src/features/booking/components/BookingDetail.tsx
// 動態載入:把 client 元件變成 lazy chunk
"use client";
import dynamic from "next/dynamic";
const BookingModal = dynamic(() => import("./BookingModal"), {
ssr: false, // 不在伺服器端 render,純 client-only
loading: () => <p>載入中…</p>,
});
export function BookingDetail() {
return (
<div>
<h2>預約詳情</h2>
<BookingModal />
</div>
);
}
ssr: false 是 App Router 的特殊語意:這個元件不會在伺服器 render,瀏覽器第一次遇到才下載 chunk。對「只在 modal 開啟時需要」「只在 hover 時顯示」這類元件特別有用。對照後端,這就像 Django 的 {% include %} 改成 AJAX 動態載入。
另一個重要的實務細節是「React Compiler」。React 19 在 Next.js 15 內開始啟用 React Compiler,能自動把 useState、useMemo、useCallback 等記憶化操作編譯掉,減少手動最佳化的負擔。在 next.config.ts 啟用之後,開發體驗會明顯改善——大部分 useState 都能直接寫、不必擔心 re-render 帶來的連鎖影響。React Compiler 目前是 opt-in(Next.js 15 預設關閉),寫法是在 next.config.ts 加 experimental: { reactCompiler: true }。
最後是「useOptimistic 與快取策略的搭配」。樂觀更新本質上是「在 client 端暫時相信操作成功」,但實際的資料流是「伺服器確認 → 重新 revalidate」。如果你的資料用 React Query / SWR 管理,樂觀更新配合 invalidate 的寫法是「setOptimistic → mutate → onSuccess 重新抓、onError 回滾」。今天寫的範例直接用內建 useOptimistic,原因是它跟 React Server Component 整合得最乾淨;如果未來資料層換成 TanStack Query,可以同時保留兩套機制——useOptimistic 處理立即回饋、Query 處理背景同步。
小結
今天把 Client Component 的設計原則與實作細節展開成四塊。第一塊是「hydration 的工作原理」:Server Component 在伺服器 render、瀏覽器收到 HTML 與 RSC payload、React hydrate 上去掛事件、之後才能回應使用者互動。理解這條鏈能解釋大部分 hydration warning 與「畫面閃爍」的成因。第二塊是「"use client" 的精準放置」:葉節點優先、需要互動的最小單元才標。第三塊是「樂觀更新」的標準寫法:useOptimistic 給立即回饋、useTransition 避免阻塞、try/catch 處理失敗回滾。第四塊是 React 19 的三個新 Hook:useOptimistic、useTransition、useFormStatus,分別解決「立刻更新」「非阻塞」「送出狀態」三個互動設計痛點。
這套設計對 booking 預約系統的價值是「操作延遲感知降到零」。使用者按下「取消預約」按鈕的瞬間,按鈕就變成「已取消」,不用等伺服器回應;即使後端慢了 500 毫秒,使用者體驗仍然順暢。對照後端的「request → response → render」三步驟,這套設計把第一步的回應時間從 100–500ms 壓到 0ms,是前端互動品質的巨大提升。明天 Day 21 會把表單與 Server Actions 展開,把「送出表單」的整條流程從「Client fetch」升級為「Server Action」,進一步減少 client-side 程式碼。
結語
明天,我們會正式進入 Day 21「表單與 Server Actions」。我們會把 <form> 與 Server Actions 結合,讓表單送出不走傳統的 onSubmit + fetch、而是走框架內建的「直接在伺服器執行」的 action。我們會看到 useActionState 怎麼處理表單狀態、useFormStatus 怎麼讀送出進度、revalidate 怎麼自動刷新清單。Day 21 結束時,你會知道為什麼「Server Actions 是表單的最佳化版」以及為什麼它把 booking 預約的建立流程縮短一半程式碼。
延伸資源
- React 19 官方 release notes(useOptimistic、useTransition、useActionState):
https://react.dev/blog/2024/12/05/react-19 - Next.js 15 官方 Server Components 章節:
https://nextjs.org/docs/app/building-your-application/rendering/server-components - Next.js 15 官方 Client Components 章節:
https://nextjs.org/docs/app/building-your-application/rendering/client-components - React 官方說明:hydrate 的運作原理:
https://react.dev/reference/react-dom/client/hydrateRoot - React Compiler(Next.js 15 opt-in):
https://nextjs.org/docs/app/building-your-application/optimizing/react-compiler
留言
張貼留言