React Patterns
慣用的 React 18/19 模式,用於建構穩健、易於存取、高效能的元件樹。
何時啟動
- 編寫或修改 React 函式元件、自訂 Hooks 或元件樹
- 審查 JSX/TSX 檔案
- 設計狀態形狀或元件組合
- 遷移 Class 元件或舊式大量使用
forwardRef/useEffect的程式碼 - 在 local state、提升狀態、Context 和外部儲存之間做選擇
- 使用 Server Components / Client Components(Next.js App Router、RSC)
- 使用 React 19 actions 或受控輸入實作表單
- 使用 TanStack Query / SWR / RSC 連接資料擷取
核心原則
1. 渲染是 Prop 和 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. Side Effects 應在渲染之外
副作用、突變、網路請求和訂閱應放在事件處理器或 useEffect 中——絕不在渲染本體中。
3. 組合優於繼承
React 沒有元件的繼承模型。應使用 children、render props 或 component props 進行組合。
Hooks 規範
完整規則集請參閱 rules/react/hooks.md。重點如下:
- 僅在最上層使用,不可條件式使用
- 清理每個訂閱、計時器、監聽器
- 當新的狀態依賴舊狀態時,使用 functional updater(
setX(prev => prev + 1)) - 預設立場:不要 memoize——僅在 profiler 或依賴鏈證明有必要時才加上
useMemo/useCallback - 僅當相同 hook 序列出現在 2 個以上元件時,才提取出自訂 hook
狀態位置決策樹
僅一個元件使用?
-> 在該元件內部使用 useState
父元件加上少數後代使用?
-> 提升到最近的共同祖先
跨遠距離分支使用,且讀取頻率低(主題、認證、語系)?
-> React Context
高頻率更新且跨樹共享?
-> 外部儲存(Zustand、Jotai、Redux Toolkit)
從伺服器推導?
-> 伺服器狀態函式庫(TanStack Query、SWR、RSC fetch)
大多數頁面不需要 Context 或 global store。在重複提升變得痛苦之前,請抵抗抽象化的誘惑。
Server / Client Components (RSC)
// Server Component - 預設,非同步,永遠不為自己傳送 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} />;
}
// Client Component - 使用 "use client" 選擇加入
"use client";
export function AddToCartButton({ productId }: { productId: string }) {
const [pending, startTransition] = useTransition();
return (
<button
disabled={pending}
onClick={() => startTransition(() => addToCart(productId))}
>
{pending ? "Adding..." : "Add to cart"}
</button>
);
}
邊界:
- Server -> Client:傳遞可序列化的 props 或
children - Client -> Server:透過
<form action={...}>或從事件處理器命令式呼叫 Server Actions - 絕不要從 Client Component 檔案
importServer Component——應透過children組合它們
Suspense + Error Boundaries
<ErrorBoundary fallback={<ErrorView />}>
<Suspense fallback={<UserSkeleton />}>
<UserDetail id={id} />
</Suspense>
</ErrorBoundary>
- 將 Suspense 邊界放在資料附近,而非路由根節點——漸進式揭示內容
- Error Boundary 仍然是 class API;使用
react-error-boundary獲得 hook 友善的包裝 - 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: "Invalid input" };
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}>Save</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">Profile</Tabs.Trigger>
<Tabs.Trigger value="settings">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 - 在路由變更和 modal 開啟/關閉時管理焦點
- 在元件測試中執行
axe(參閱 skills/react-testing) - 交叉連結:skills/accessibility/SKILL.md 涵蓋了 WCAG 標準和模式庫
路由
此技能與路由器無關。上述模式適用於 React Router、TanStack Router、Next.js App Router、Remix Router。路由器特定的模式(loaders、actions、巢狀佈局)請遵循路由器的文件——這些是建構在 React 核心之上的框架關注點。
不包含的範圍(指標章節)
- Next.js 特定內容:App Router 資料載入、Route Handlers、Middleware、Parallel Routes——這是不同的關注點,請使用 Next.js 文件
- React Native:平台特定的模式差異足夠大,應有獨立的
react-native-patterns技能(目前不存在) - Remix:Loader/action 慣例與 RSC 重疊,但請遵循 Remix 文件
相關連結
- 規則:rules/react/——編碼風格、hooks、模式、安全性、測試
- 技能:react-performance 用於 Vercel 衍生的效能規則集,frontend-patterns 用於跨框架 UI 關注點,accessibility,angular-developer 用於框架比較
- Agent:
react-reviewer用於程式碼審查,react-build-resolver用於建構/bundler 錯誤 - 指令:
/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">Send</button>
</form>
</>
);
}
拆分 context 以避免渲染瀑布效應
// 兩個 context:一個很少變動,一個頻繁變動
const ThemeContext = createContext<Theme>("light");
const NotificationsContext = createContext<Notification[]>([]);
// 一個只消費 ThemeContext 的元件,在 notifications 變動時並不會重新渲染