React 模式
適用於 React 18/19 的慣用模式,幫助您建構穩健、易於存取且高效能的元件樹。
何時啟用
- 編寫或修改 React 函式元件、自訂 Hook 或元件樹
- 審查 JSX/TSX 檔案
- 設計狀態形狀或元件組合方式
- 遷移類別元件或大量使用
forwardRef/useEffect的舊有程式碼 - 在本地狀態、提升狀態、Context 與外部 Store 之間做出選擇
- 處理伺服器元件 / 客戶端元件(Next.js App Router、RSC)
- 使用 React 19 動作或受控輸入來實作表單
- 使用 TanStack Query / SWR / RSC 來串接資料獲取
核心原則
1. 渲染是 Props 與 State 的純函數
// 好的做法:在渲染期間推導
function Cart({ items }: { items: CartItem[] }) {
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return <span>{formatMoney(total)}</span>;
}
// 糟糕的做法:將推導出的狀態另外儲存
function Cart({ items }: { items: CartItem[] }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0));
}, [items]);
return <span>{formatMoney(total)}</span>;
}
在 useEffect 中處理推導狀態會增加一次渲染循環,可能導致狀態不同步,並使資料流變得模糊不清。
2. 副作用在渲染體外部處理
副作用、突變、網路呼叫和訂閱都應該放在事件處理函式或 useEffect 中 — 絕對不要在渲染體中處理。
3. 組合優於繼承
React 沒有元件的繼承模型。請使用 children、render props 或元件 props 來進行組合。
Hooks 規範
請參閱 rules/react/hooks.md 以獲得完整的規則集。重點提示:
- 只能在頂層呼叫,不可條件式呼叫
- 每個訂閱、間隔器、監聽器都需要清理
- 當新狀態依賴於舊狀態時,使用函數更新器 (
setX(prev => prev + 1)) - 預設立場:不要進行記憶化 — 只有在效能分析器或依賴鏈證明有必要時,才加入
useMemo/useCallback - 僅當同一組 Hook 序列出現在 2 個以上的元件時,才提取出自訂 Hook
狀態位置決策樹
只被一個元件使用?
-> 在該元件內使用 useState
被父元件和少數子孫元件使用?
-> 提升到最近的共同祖先元件
被分散的樹狀分支使用,且讀取頻率低(主題、驗證、語言環境)?
-> React Context
在整個樹狀結構中高頻率更新?
-> 外部 Store(Zustand、Jotai、Redux Toolkit)
從伺服器端推導?
-> 伺服器狀態函式庫(TanStack Query、SWR、RSC fetch)
大多數頁面並不需要 Context 或全域 Store。在重複的狀態提升操作變得令人痛苦之前,請抵制住進行抽象化的衝動。
伺服器 / 客戶端元件 (RSC)
// 伺服器元件 - 預設,async,本身不會傳送 JS 給客戶端
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.product.findUnique({ where: { id: params.id } });
if (!product) notFound();
return <ProductView product={product} />;
}
// 客戶端元件 - 使用 "use client" 選擇加入
"use client";
export function AddToCartButton({ productId }: { productId: string }) {
const [pending, startTransition] = useTransition();
return (
<button
disabled={pending}
onClick={() => startTransition(() => addToCart(productId))}
>
{pending ? "新增中..." : "加入購物車"}
</button>
);
}
邊界:
- 伺服器 -> 客戶端:傳遞可序列化的 props 或
children - 客戶端 -> 伺服器:透過
<form action={...}>或從事件處理函式中命令式地呼叫伺服器動作 - 絕對不要在客戶端元件檔案中
import伺服器元件 — 透過children來組合它們
Suspense + Error Boundaries
<ErrorBoundary fallback={<ErrorView />}>
<Suspense fallback={<UserSkeleton />}>
<UserDetail id={id} />
</Suspense>
</ErrorBoundary>
- 將 Suspense 邊界放在靠近資料的地方,而不是路由根節點 — 逐步揭露內容
- Error Boundary 仍然是類別 API;對於對 Hook 友善的封裝,請使用
react-error-boundary - Error Boundary 會捕捉在其子元件的渲染、生命週期和建構子期間拋出的錯誤 — 不會捕捉事件處理函式或非同步程式碼中的錯誤
表單
React 19 表單動作(新程式碼的首選)
"use client";
import { useActionState } from "react";
const initial = { error: null as string | null };
async function updateUserAction(_prev: typeof initial, formData: FormData) {
"use server";
const parsed = UserSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: "無效的輸入" };
await db.user.update({ where: { id: parsed.data.id }, data: parsed.data });
return { error: null };
}
export function UserForm() {
const [state, formAction, pending] = useActionState(updateUserAction, initial);
return (
<form action={formAction}>
<input name="name" required />
<button type="submit" disabled={pending}>儲存</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
);
}
受控輸入
當輸入值的變更需要驅動其他 UI、在每次按鍵時進行格式化,或實作即時驗證時,請使用受控輸入。
複雜表單
對於多步驟表單、動態欄位陣列或跨欄位驗證:請使用函式庫(React Hook Form、TanStack Form)。對於超出簡單複雜度的表單,自行實作狀態管理是一個維護上的陷阱。
資料獲取決策矩陣
| 需求 | 工具 |
|---|---|
| Next.js App Router 中每次請求的資料 | RSC await fetch() |
| 客戶端快取 + 突變 + 失效 | TanStack Query |
| 輕量級客戶端快取 + 重新驗證 | SWR |
| 即時訂閱 | Server-Sent Events、WebSockets 或函式庫的訂閱 API |
| 一次性的發送後忘記 | 事件處理函式中的 fetch() |
避免使用 useEffect + fetch 來處理應用程式資料 — 會導致競爭條件、無快取、無重試、無 Suspense 整合。
組合食譜
透過 children 的插槽
<Layout>
<Header />
<Main>{content}</Main>
</Layout>
具名插槽
<Page header={<Nav />} sidebar={<Filters />}>
<Results />
</Page>
複合元件(透過 Context 共享狀態)
<Tabs defaultValue="profile">
<Tabs.List>
<Tabs.Trigger value="profile">個人資料</Tabs.Trigger>
<Tabs.Trigger value="settings">設定</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel value="profile"><Profile /></Tabs.Panel>
<Tabs.Panel value="settings"><Settings /></Tabs.Panel>
</Tabs>
Render Prop / Function-as-Child
當父元件需要傳遞參數給渲染結果時很有用:
<DataLoader id={id}>
{({ data, isLoading }) => isLoading ? <Spinner /> : <UserCard user={data} />}
</DataLoader>
現代的替代方案是:一個回傳相同形狀的 Hook (useData(id)) — 通常更簡潔。
效能
何時 React.memo 真正有幫助
僅在以下情況才將元件包裝在 React.memo 中:
- 該元件頻繁重新渲染
- 其 props 在多次渲染之間通常保持不變
- 其渲染成本可被量測到是昂貴的
React.memo 在每次渲染時都會增加一個相等性檢查。如果 props 在大多數渲染時都不同,那麼這個檢查就是純粹的開銷。
避免渲染串聯
- 盡可能將狀態下推而不是上提
- 拆分 Context:每個關注點一個 Context,這樣
themeContext的變更就不會重新渲染 auth 的消費者 - 對於外部狀態函式庫,使用
useSyncExternalStore— 對於安全的並行渲染是必需的
列表
- 提供穩定的
keyprop(資料庫 ID,而不是陣列索引) - 當可見項目數量超過約 50 且行內容不簡單時,使用
@tanstack/react-virtual或react-window對長列表進行虛擬化
無障礙優先的組合
- 在求助於
role屬性之前,始終先渲染語義化的 HTML(<button>、<a>、<nav>、<main>) - 每個可互動元素都必須可以透過鍵盤到達
- 表單輸入需要標籤 — 使用
<label htmlFor>,如果視覺上由圖示標記,則使用aria-label - 在路由變更和模態框開啟/關閉時管理焦點
- 在元件測試中執行
axe(請參閱 skills/react-testing) - 交叉連結:skills/accessibility/SKILL.md 涵蓋了 WCAG 標準和模式庫
路由
這項技能與路由無關。上述模式適用於 React Router、TanStack Router、Next.js App Router、Remix Router。路由特定的模式(載入器、動作、巢狀佈局)請遵循路由器的文件 — 這些是在 React 核心之上層層疊加的框架關注點。
範圍外(指向章節)
- Next.js 細節:App Router 資料載入、Route Handlers、Middleware、Parallel Routes — 這是另一個關注點,請使用 Next.js 文件
- React Native:平台特定的模式差異很大,需要一個獨立的
react-native-patterns技能(目前不存在) - Remix:載入器/動作的慣例與 RSC 重疊,但請遵循 Remix 文件
相關資源
- 規則:rules/react/ — 編碼風格、hooks、模式、安全性、測試
- 技能:react-performance 包含了 Vercel 衍生的效能規則集、frontend-patterns 處理跨框架的 UI 關注點、accessibility、angular-developer 用於框架比較
- 代理人:
react-reviewer用於程式碼審查、react-build-resolver用於建置/打包錯誤 - 指令:
/react-review、/react-build、/react-test
範例
用於防抖搜尋的自訂 Hook
function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
function SearchBox() {
const [query, setQuery] = useState("");
const debounced = useDebounce(query, 300);
const { data } = useQuery({
queryKey: ["search", debounced],
queryFn: () => searchApi(debounced),
enabled: debounced.length > 0,
});
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Results items={data ?? []} />
</>
);
}
使用 React 19 useOptimistic 的樂觀 UI
"use client";
import { useOptimistic } from "react";
export function MessageList({ messages }: { messages: Message[] }) {
const [optimistic, addOptimistic] = useOptimistic(
messages,
(state, newMessage: Message) => [...state, newMessage],
);
async function send(formData: FormData) {
const text = String(formData.get("text"));
addOptimistic({ id: "pending", text, sender: "me" });
await saveMessage(text);
}
return (
<>
<ul>{optimistic.map((m) => <li key={m.id}>{m.text}</li>)}</ul>
<form action={send}>
<input name="text" />
<button type="submit">傳送</button>
</form>
</>
);
}
拆分 Context 以避免渲染串聯
// 兩個 Context:一個很少變更,一個頻繁變更
const ThemeContext = createContext<Theme>("light");
const NotificationsContext = createContext<Notification[]>([]);
// 一個只使用 ThemeContext 的元件在通知變更時不會重新渲染