FE Day 11 狀態管理:Context、提升與拆分
執行需求:CPU 可跑。今天是系列的第十一篇。前兩天我們用 useState 管理元件內部的狀態、用 useEffect 同步到外部系統、用自訂 Hook 抽出可重用的邏輯。但當狀態需要在「多層元件」之間共享——例如「目前登入的使用者」、「主題(亮/暗)」、「語系」——光靠 props 一層層傳下去就會出現「props drilling」這種醜陋的程式碼。React 內建的 Context API 就是為了解決這個問題而設計。今天我們會把昨天的 usePersistentState 與 useFetch 組合成「全域使用者狀態 + 持久化」的管理系統,並用一個完整的「預約管理系統」的後台案例,把提升狀態、Context、useReducer 三種模式全部演練一次。整篇範例都在本機 CPU 跑得起來,不依賴雲端服務。
引言
寫後端的時候我們對「跨模組共享狀態」很熟悉:全域變數、單例物件、依賴注入容器。前端這邊因為 React 的「單向資料流」,狀態預設只能在元件樹中往下傳遞。當元件樹只有兩三層時,這種傳遞很自然;當元件樹長到五層、十層,把同一個 props 從最外層一直挖到最深處的子元件,就會出現「props drilling」——中間那幾層元件根本不關心這個 props,卻被迫當傳話的鏈條。
React 內建三個機制處理這個問題。第一個是「提升狀態」(lift state up):把狀態搬到共用它的最近共同祖先,讓祖先透過 props 分別傳給需要的子元件。這個手法最單純,但對深層元件樹仍然囉嗦。第二個是 Context API:在元件樹上層「宣告一個 Context」,下層任何元件都可以「訂閱」這個 Context 拿到值,不需要中間層傳遞。第三個是 useReducer:把多個相關的 state 與更新邏輯打包成 reducer,讓 Context 的內容更結構化。今天三個都會講,並且會示範它們在「預約管理系統」這個貫穿專案中的角色。
今天要回答五個問題:第一,什麼時候該用提升狀態,什麼時候該用 Context?第二,Context 的 API 怎麼寫?第三,Context 的效能陷阱是什麼?第四,什麼時候該升級到 useReducer 或外部狀態函式庫?第五,怎麼把昨天做的 Hook 跟 Context 接起來?我們會延續昨天的專案,把 useFetch 與 usePersistentState 組合起來做一個「全域登入狀態 + 主題切換 + 跨元件通知」的範例。整篇閱讀時間約 35 分鐘,動手做大約 40 分鐘。
提升狀態:最簡單的跨元件共享
在引入 Context 之前,先看一下「提升狀態」這個最簡單的手法。它的概念是把狀態從子元件搬到「共用它的最近共同祖先」,讓祖先負責持有 state,並透過 props 分別傳給需要它的子元件。後端工程師可以把這個手法想成「把區域變數提升到 service 層」。
// src/components/FilterableList.tsx
// 提升狀態範例:篩選條件與清單狀態都由父元件持有
import { useState } from "react";
type Item = { id: number; name: string; tag: string };
const allItems: Item[] = [
{ id: 1, name: "週三瑜伽課", tag: "瑜珈" },
{ id: 2, name: "週四重訓班", tag: "重訓" },
{ id: 3, name: "週五皮拉提斯", tag: "皮拉提斯" },
];
export function FilterableList() {
// 篩選條件住在父元件:兩個子元件都讀得到
const [keyword, setKeyword] = useState("");
const filtered = allItems.filter((it) => it.name.includes(keyword));
return (
<div className="space-y-3">
<SearchInput value={keyword} onChange={setKeyword} />
<ItemList items={filtered} />
</div>
);
}
function SearchInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<input
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="搜尋課程名稱"
className="w-full rounded border border-slate-300 p-2 text-sm"
/>
);
}
function ItemList({ items }: { items: Item[] }) {
return (
<ul className="space-y-1">
{items.map((it) => (
<li key={it.id} className="rounded border border-slate-200 p-2 text-sm">
{it.name} <span className="text-slate-400">#{it.tag}</span>
</li>
))}
</ul>
);
}
這個範例展示「提升狀態」的核心:keyword 與 filtered 都不住在 SearchInput 或 ItemList 裡,而是住在父元件 FilterableList。兩個子元件只用 props 接收自己需要的部分。當元件樹只有兩三層時,這個寫法最直覺,效能也最好——React 只在父元件 re-render 時才會重新渲染子元件,但子元件可以用 React.memo 進一步優化(Day 25 會講)。
提升狀態的限制是「元件樹深度」。如果共用同一個狀態的元件在元件樹中相隔五層以上,props 就要被中間五層元件「傳話」下去,這種「傳話」沒有意義、也容易出錯(中間某層忘記傳、某層改壞了),這就是「props drilling」。當這個問題出現時,就該考慮 Context。
Context API:跨多層元件共享狀態
React 的 Context API 讓你可以在元件樹的某個上層「宣告一個全域變數」,下層任何元件都可以「訂閱」這個變數,而不需要中間層傳遞 props。後端工程師可以把 Context 想成「全域變數的 React 版」——但比真正的全域變數安全,因為它只能在 Provider 子樹範圍內讀寫。
Context 由三個角色組成:
- 建立 Context:用
createContext建立一個 Context 物件,附帶預設值。 - 提供 Context:在元件樹上層用
MyContext.Provider value={"..."}把值注入。 - 消費 Context:下層元件用
useContext(MyContext)取得值。
下面是 Context 的標準寫法,以「主題切換」為例:
// src/contexts/ThemeContext.tsx
// 建立 Theme Context:儲存目前主題(light / dark)與切換函式
import { createContext, useContext } from "react";
export type Theme = "light" | "dark";
export type ThemeContextValue = {
theme: Theme;
toggle: () => void;
};
// 建立 Context,並提供預設值(用在沒有 Provider 的場景)
export const ThemeContext = createContext<ThemeContextValue>({
theme: "light",
toggle: () => {},
});
// 自訂 Hook:封裝 useContext 呼叫,避免散落在各處
export function useTheme() {
return useContext(ThemeContext);
}
這段程式建立一個 Context,並提供一個 useTheme 自訂 Hook 來消費它。Context 本體是「合約」,實際的值由 Provider 注入。預設值在「元件被用在 Provider 之外」時生效,正式開發通常不會用到,但它讓 TypeScript 型別檢查能通過。
接著寫 Provider。Provider 把 Context 的值注入到子樹:
// src/contexts/ThemeProvider.tsx
// Theme Provider:把主題值與切換函式注入子樹
import { useState, type ReactNode } from "react";
import { ThemeContext, type Theme } from "./ThemeContext";
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
const toggle = () => setTheme((t) => (t === "light" ? "dark" : "light"));
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
Provider 是一個普通的 React 元件,內部用 useState 管理狀態,並把狀態連同更新函式打包成物件透過 value 注入。任何被 {children} 渲染出來的子元件,都可以用 useTheme() 拿到 theme 與 toggle。
下層元件就能直接讀取主題,不需要 props 傳遞:
// src/components/ThemeToggleButton.tsx
// 主題切換按鈕:直接從 Context 讀取主題
import { useTheme } from "../contexts/ThemeContext";
export function ThemeToggleButton() {
const { theme, toggle } = useTheme();
return (
<button
type="button"
onClick={toggle}
className="rounded bg-slate-200 px-3 py-1 text-sm hover:bg-slate-300"
>
目前主題:{theme === "light" ? "亮" : "暗"}(點擊切換)
</button>
);
}
把 Provider 掛到 App 最外層,所有子元件就都能用 useTheme:
// src/App.tsx
// 把 ThemeProvider 放在最外層,讓所有子元件都能讀到主題
import { ThemeProvider } from "./contexts/ThemeProvider";
import { ThemeToggleButton } from "./components/ThemeToggleButton";
export default function App() {
return (
<ThemeProvider>
<main className="min-h-screen bg-white p-8 text-slate-900">
<h1 className="text-2xl font-bold">預約管理後台</h1>
{/* 無論這個按鈕在元件樹多深,都能讀到主題 */}
<div className="mt-4">
<ThemeToggleButton />
</div>
</main>
</ThemeProvider>
);
}
這個範例展示 Context 的核心價值:ThemeToggleButton 在元件樹的任何位置(即使隔了五層 Layout)都能讀到主題,不需要中間層傳 props。這對「跨多層共享狀態」非常方便。
Context 的兩個效能陷阱
Context 並不是萬靈丹。它有兩個常見的效能陷阱,新手一定要知道。
第一個陷阱:「Context 的值每次都建立新的物件,會讓所有訂閱的元件全部 re-render」。這是 Context 最常見的踩雷:
// 反例:每次 render 都建立新的 value 物件,導致所有訂閱者 re-render
function BadProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
return (
<ThemeContext.Provider value={{ theme, toggle: () => setTheme((t) => (t === "light" ? "dark" : "light")) }}>
{children}
</ThemeContext.Provider>
);
}
這段 Provider 每次 render 都會建立新的 value 物件。React 用「淺比較」判斷 Context 的值是否變化,新物件每次都「變」,所以所有訂閱 useTheme() 的元件都會重新渲染,即使 theme 值本身沒變。修法是把 value 用 useMemo 包起來,並把 toggle 用 useCallback 包起來:
// 正解:用 useMemo 把 value 包起來,避免不必要的 re-render
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { ThemeContext, type Theme } from "./ThemeContext";
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>("light");
const toggle = useCallback(() => {
setTheme((t) => (t === "light" ? "dark" : "light"));
}, []);
const value = useMemo(() => ({ theme, toggle }), [theme, toggle]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
第二個陷阱:「把 Context 拆太粗」。如果一個 Context 把「主題」、「使用者」、「語系」全部打包在一起,任何一個欄位變動都會讓所有訂閱者 re-render。修法是拆成多個小 Context,每個 Context 只管一件事:「ThemeContext」、「UserContext」、「LocaleContext」。這個原則跟後端的「單一職責」相同。
useReducer:當 state 邏輯變複雜
當 Context 裡的狀態只有一個布林值或一個字串時,useState 就夠了。但當狀態變成「多個欄位 + 多種更新動作」,例如「目前登入的使用者」、「載入狀態」、「錯誤訊息」、「token 過期時間」——直接用 useState 會讓 Provider 變得冗長。這時可以改用 useReducer:把 state 與更新邏輯打包成 reducer 函式。
// src/contexts/AuthContext.tsx
// 用 useReducer 管理登入狀態:多欄位 + 多動作
import { createContext, useContext, useReducer, type ReactNode } from "react";
export type AuthUser = { id: string; name: string; role: "user" | "admin" };
export type AuthState = {
user: AuthUser | null;
loading: boolean;
error: string | null;
};
// 動作:把所有可能的更新打包成 discriminated union
export type AuthAction =
| { type: "login/start" }
| { type: "login/success"; user: AuthUser }
| { type: "login/error"; error: string }
| { type: "logout" };
function reducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case "login/start":
return { ...state, loading: true, error: null };
case "login/success":
return { user: action.user, loading: false, error: null };
case "login/error":
return { user: null, loading: false, error: action.error };
case "logout":
return { user: null, loading: false, error: null };
}
}
const initial: AuthState = { user: null, loading: false, error: null };
type AuthContextValue = {
state: AuthState;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue>({
state: initial,
login: async () => {},
logout: () => {},
});
export function useAuth() {
return useContext(AuthContext);
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, initial);
const login = async (email: string, password: string) => {
dispatch({ type: "login/start" });
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const user = (await res.json()) as AuthUser;
dispatch({ type: "login/success", user });
} catch (err) {
dispatch({ type: "login/error", error: err instanceof Error ? err.message : "Unknown" });
}
};
const logout = () => dispatch({ type: "logout" });
return (
<AuthContext.Provider value={{ state, login, logout }}>
{children}
</AuthContext.Provider>
);
}
這個 Provider 用 useReducer 取代 useState,原因是「登入」是一個有三個階段(start、success、error)的流程,加上 logout 共有四個動作。如果用 useState 寫,Provider 內部要呼叫 setState 三到四次;改用 useReducer 之後,邏輯集中在 reducer 函式,Provider 只需要呼叫 dispatch。這個模式跟 Redux 是同一個家族——只是 useReducer 內建在 React,不用裝額外套件。
下游元件就可以直接讀取登入狀態:
// src/components/Header.tsx
// 頁首:根據登入狀態顯示「登入」或「使用者名稱 + 登出」
import { useAuth } from "../contexts/AuthContext";
export function Header() {
const { state, logout } = useAuth();
return (
<header className="flex items-center justify-between border-b border-slate-200 p-4">
<h1 className="text-lg font-semibold">預約管理</h1>
{state.user ? (
<div className="flex items-center gap-3 text-sm">
<span>Hello, {state.user.name}</span>
<button
type="button"
onClick={logout}
className="rounded bg-slate-200 px-3 py-1 hover:bg-slate-300"
>
登出
</button>
</div>
) : (
<span className="text-sm text-slate-500">未登入</span>
)}
</header>
);
}
把昨天的 Hook 與今天的 Context 接起來
昨天的 usePersistentState 可以讓 Context 的值自動同步到 localStorage,做成「重整後仍保留登入狀態」的效果:
// src/contexts/AuthProvider.tsx(升級版)
// 把昨天的 usePersistentState 整合進 AuthProvider
import { useReducer, useMemo, type ReactNode } from "react";
import { AuthContext, type AuthState, initialState, reducer } from "./authReducer";
import { usePersistentState } from "../hooks/usePersistentState";
export function AuthProvider({ children }: { children: ReactNode }) {
// 把整個 state 序列化到 localStorage;重整後自動讀回
const [persisted, setPersisted] = usePersistentState<AuthState>(
"auth:state",
initialState,
);
const [state, dispatch] = useReducer(reducer, persisted);
// 當 reducer 更新 state 時,把結果同步回 localStorage
// 用 useEffect 包起來(昨天教的模式)
useEffect(() => {
setPersisted(state);
}, [state, setPersisted]);
// ...login / logout 同上
}
這個升級版讓登入狀態在重整後仍保留:使用者不必每次重新整理都重新登入。實務上要注意「token 過期」的處理——通常會在 Day 22「認證與 session 管理」再展開,這裡先展示「Hook + Context 怎麼組合」。
什麼時候該升級到外部狀態函式庫
Context + useReducer 已經能處理大多數前端專案的狀態管理。但當以下情況出現時,就該考慮外部函式庫(Zustand、Jotai、Redux Toolkit):
- Context 太多:應用有 5 個以上 Context,每個有自己的 Provider,包裝鏈太長。
- 跨頁籤同步:不同分頁的狀態需要同步,Context 無法直接處理(要靠 storage 事件,昨天 Day 10 教的 usePersistentState 擴充版可以幫上忙)。
- 狀態很大且需要選擇器:某些元件只需要其中一個欄位,但 Context 變動會讓所有訂閱者 re-render。Zustand 等函式庫提供 selector 機制,能讓元件只訂閱自己需要的部分。
- DevTools 需求:需要時間旅行除錯、狀態快照匯出。Redux DevTools 提供完整的體驗。
本系列在 Day 31–44 的「預約管理系統」會繼續用 Context + useReducer 主軸,只在「跨元件通知」與「全域快取」需要時才升級。原因是 Context 對 React 內建、零依賴、新人易上手,符合「能內建就不裝外部」的原則。
常見錯誤與踩雷
第一個踩雷是「Provider 沒放在最外層」。Context 只能向下傳遞,如果 Provider 放在某個深層元件,它下層的元件讀不到,上層的元件也讀不到。常見的錯誤是把 ThemeProvider 放在 App 元件裡面、卻在某個路由的 layout 元件外面用 useTheme,結果讀到預設值。修法是把 Provider 放在最靠近根節點的位置。
第二個踩雷是「忘記把 Provider 包到所有需要的地方」。例如你寫了 UserContext,但只有登入頁有用 Provider,導致後台頁面讀不到 user 資料。修法是把所有 Provider 集中在 src/providers/AppProviders.tsx,並在 main.tsx 一次包好。
第三個踩雷是「Provider 的 value 直接放物件字面值」。第一個陷阱提過,每次 render 都建立新的物件會讓所有訂閱者 re-render。即使這個 re-render 不影響正確性,也會浪費效能。修法是用 useMemo。
第四個踩雷是「reducer 內呼叫 setState 或 fetch」。Reducer 必須是純函式:給定相同的 state 與 action,必須回傳相同的結果。如果 reducer 內呼叫 fetch 或 setState,會讓狀態變得不可預期,也無法被 DevTools 時間旅行。修法是把副作用放在 Provider 的事件處理函式(例如 login),只把結果用 dispatch 送進 reducer。
第五個踩雷是「在 SSR 環境忘記處理」。Context 的 Provider 在 Next.js 的 Server Component 裡建立時,Client Component 訂閱到的會是「序列化後的值」,無法即時更新。修法是把 Provider 標記為 Client Component,或在 Server Component 裡用 getServerSession 取得資料再傳給 Provider。
效能與實務提醒
Context 的效能影響主要在「Provider 的 value 變化時,所有訂閱者 re-render」。可以用三個手段降低影響。第一,把 Context 拆細:主題一個、使用者一個、語系一個,避免單一 Context 變動影響太多元件。第二,用 useMemo 把 value 包起來,避免物件參考不穩定。第三,用 React.memo 把「不需要訂閱 Context 的子元件」包起來,這樣 Provider re-render 不會拖累它們。
另一個實務提醒是「Context 適合低頻變動的狀態」。例如「目前使用者」、「主題」、「語系」這些「應用程式生命週期內只變幾次」的狀態,Context 沒問題。但「捲動位置」、「滑鼠座標」、「動畫幀數」這種「每秒變幾十次」的狀態,就不該放 Context——這些資料變動太頻繁,會讓整個應用陷入 re-render 風暴。這類狀態適合用 ref 或外部狀態函式庫。
最後提一個測試注意事項:「Provider 在測試裡要手動包」。當元件內部呼叫 useAuth(),但測試渲染時沒有 Provider,會拿到預設值(空函式、空狀態)。修法是在測試裡用 render( 包起來,或建立一個測試專用的 wrapper。Day 15「測試」會再展開這個主題。
另外要留意 Provider 的生命週期。Context 的值是在 Provider 元件內部用 useState 或 useReducer 持有的,當 Provider 從畫面移除時(典型情境是路由切換到另一個 layout),Context 的值也會跟著消失。如果某個深層子元件在 Provider 離開後仍嘗試讀取 Context,會拿到預設值(因為它已經不在 Provider 子樹內)。這是 React 的設計——Provider 是狀態的「邊界」,離開這個邊界就看不到值。實務上 App 的根 Provider 通常不會被移除,所以這個問題不太常見;但在設計多個獨立 Provider(例如登入前 vs 登入後的 Layout)時要特別注意。
另一個團隊協作的實務:「Provider 集中在 src/providers/AppProviders.tsx」。把所有 Provider 集中在一個檔案,未來要看「這個 App 套了哪些 Provider」只要看這個檔案。新成員加入時,也容易從這裡理解 Context 的整體結構。下面是一個典型的 AppProviders:
// src/providers/AppProviders.tsx
// 把所有 Provider 集中起來,方便管理與測試
import type { ReactNode } from "react";
import { ThemeProvider } from "../contexts/ThemeProvider";
import { AuthProvider } from "../contexts/AuthProvider";
export function AppProviders({ children }: { children: ReactNode }) {
return (
<ThemeProvider>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
);
}
用 AppProviders 包好之後,main.tsx 只要呼叫一次 AppProviders 就把所有 Context 套上去,不必在每個入口檔案記得 import 每個 Provider。
小結
今天我們把跨元件狀態管理從「提升狀態」到「Context API」再到「useReducer」一次走完。重點回顧:提升狀態是「把狀態搬到共同祖先」,簡單直覺但對深層元件樹不友善;Context API 用 Provider/Consumer 模型讓任何深度的子元件都能讀到狀態,但要注意 value 物件的參考穩定性與 Context 拆分;useReducer 把多欄位、多動作的狀態邏輯集中到 reducer 函式,適合登入流程、表單狀態這類較複雜的場景;當 Context 太多或需要 selector 時,才考慮升級到外部狀態函式庫。我們也用 AuthProvider + usePersistentState 展示了「全域狀態 + 持久化」的組合,這個模式會在 Day 22「認證」與 Day 31 之後的「預約管理系統」繼續使用。明天 Day 12 會進入表單主題——受控元件、非受控元件、Zod 驗證、錯誤狀態的處理,這是 React 開發每天都在做的事。
結語
明天,我們會把狀態管理的焦點從「跨元件共享」轉到「表單處理」。Day 12 會介紹 React 表單的兩種模式——受控元件(value + onChange)與非受控元件(ref + defaultValue)——以及怎麼用 Zod 做欄位驗證、怎麼處理錯誤狀態、怎麼避免「每次打字都 re-render」的效能問題。我們會做一個完整的「預約建立表單」,把你熟悉的後端表單經驗搬到 React 上。記得今天的 AuthProvider 與 useAuth Hook 先留著,明天會把「表單送出時的登入狀態檢查」整合進去。
延伸資源
- React 官方〈Passing Data Deeply with Context〉(19.x,2026 年 3 月):
https://react.dev/learn/passing-data-deeply-with-context - React 官方〈Scaling Up with Reducer and Context〉(19.x):
https://react.dev/learn/scaling-up-with-reducer-and-context - React 官方〈useReducer 參考〉(19.x):
https://react.dev/reference/react/useReducer - Zustand 官方文件(Day 31 之後可能用到的輕量狀態函式庫):
https://zustand-demo.pmnd.rs/ - TypeScript Handbook〈Discriminated Unions〉(reducer 動作設計):
https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions
留言
張貼留言