TypeScript 在 React 中的 Context + Reducer 型別設計
(React + TypeScript 實務應用)
簡介
在大型 React 專案裡,全域狀態管理是不可或缺的需求。
React 官方提供的 Context 搭配 useReducer,是一套輕量且易於測試的解決方案,特別適合 中小型專案 或 功能模組化 的情境。
然而,當我們把 TypeScript 引入這個流程時,若沒有妥善設計型別,會出現以下問題:
dispatch參數類型不明確,導致 any 警告或執行時錯誤。state內的屬性在不同組件中被錯誤使用,缺乏編譯期保護。- 減速器 (reducer) 的
action結構不一致,使得維護成本大幅提升。
本文將從 概念說明、型別設計技巧、常見陷阱 到 實務應用,一步步帶你建立 安全、可維護 的 Context + Reducer 架構。
核心概念
1. 為什麼選擇 Context + Reducer
| 特性 | Context + Reducer | Redux | MobX |
|---|---|---|---|
| 官方支援 | ✅ (React 內建) | ❌ (外部套件) | ❌ |
| Boilerplate | 低 | 高 | 中 |
| 可測試性 | 高 (純函式 reducer) | 高 | 中 |
| 型別支援 | 完全由 TypeScript 控制 | 需要額外型別工具 | 需要額外型別工具 |
結論:在不需要額外套件、且希望保持型別安全的情況下,Context + Reducer 是最佳選擇。
2. 基本流程
- 定義 State 型別 – 描述全域狀態的結構。
- 定義 Action 型別 – 使用 discriminated union 讓 TypeScript 能分辨每個 action。
- 實作 Reducer – 完全純函式,根據 action 變更 state。
- 建立 Context – 包含
state與dispatch,兩者皆需型別。 - 提供 Provider – 在根組件包住子樹,傳遞已型別化的 context。
- 在子組件使用 – 透過
useContext取得state與dispatch,並在 TypeScript 的幫助下安全操作。
下面的程式碼範例會一步步展示上述每個步驟。
3. 型別設計的關鍵技巧
3.1 Discriminated Union(辨別式聯合)
// actionTypes.ts
export type CounterAction =
| { type: 'increment'; payload: number } // 增加指定數量
| { type: 'decrement'; payload: number } // 減少指定數量
| { type: 'reset' } // 重設為 0
type 欄位作為 辨別鍵,讓 TypeScript 在 switch 中自動推斷對應的 payload 型別。
3.2 使用 Readonly 防止意外變更
export interface CounterState {
readonly count: number;
}
將 state 設為 readonly,在 reducer 之外的程式碼若嘗試直接修改 state.count,編譯器會直接報錯。
3.3 為 Context 包裝型別
interface CounterContextProps {
state: CounterState;
dispatch: React.Dispatch<CounterAction>;
}
React.Dispatch<CounterAction>為dispatch的正確型別,保證只能傳入符合CounterAction結構的物件。
程式碼範例
範例 1️⃣:最簡單的計數器 Context + Reducer
// CounterContext.tsx
import React, { createContext, useReducer, useContext } from 'react';
// 1. State 型別
interface CounterState {
readonly count: number;
}
// 2. Action 型別(Discriminated Union)
type CounterAction =
| { type: 'increment'; payload: number }
| { type: 'decrement'; payload: number }
| { type: 'reset' };
// 3. 初始值
const initialState: CounterState = { count: 0 };
// 4. Reducer 實作
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'increment':
return { count: state.count + action.payload };
case 'decrement':
return { count: state.count - action.payload };
case 'reset':
return { count: 0 };
default:
// 這裡的 never 讓 TypeScript 確保所有 action 都被處理
const _: never = action;
return state;
}
}
// 5. 建立 Context
const CounterContext = createContext<{
state: CounterState;
dispatch: React.Dispatch<CounterAction>;
} | undefined>(undefined);
// 6. Provider 組件
export const CounterProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(counterReducer, initialState);
return <CounterContext.Provider value={{ state, dispatch }}>{children}</CounterContext.Provider>;
};
// 7. 自訂 Hook,方便在子組件取得 context
export const useCounter = () => {
const ctx = useContext(CounterContext);
if (!ctx) {
throw new Error('useCounter 必須在 CounterProvider 內使用');
}
return ctx;
};
說明
CounterProvider包住應用的根節點,提供型別安全的state與dispatch。useCounterHook 把undefined檢查包起來,避免忘記包 Provider 時的 runtime error。
範例 2️⃣:在子組件中使用
// CounterDisplay.tsx
import React from 'react';
import { useCounter } from './CounterContext';
export const CounterDisplay: React.FC = () => {
const { state, dispatch } = useCounter();
return (
<div>
<h2>目前計數: {state.count}</h2>
<button onClick={() => dispatch({ type: 'increment', payload: 1 })}>+1</button>
<button onClick={() => dispatch({ type: 'decrement', payload: 1 })}>-1</button>
<button onClick={() => dispatch({ type: 'reset' })}>重設</button>
</div>
);
};
重點:
dispatch的參數會被 TypeScript 完全檢查,若寫成{ type: 'increment' }(缺少 payload)編譯即失敗。
範例 3️⃣:多模組 Context(Auth + Theme)
// AppContext.tsx
import React, { createContext, useReducer, useContext } from 'react';
/* ---------- Auth ---------- */
interface AuthState {
readonly isLoggedIn: boolean;
readonly userName?: string;
}
type AuthAction =
| { type: 'login'; payload: { userName: string } }
| { type: 'logout' };
const authInitial: AuthState = { isLoggedIn: false };
function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case 'login':
return { isLoggedIn: true, userName: action.payload.userName };
case 'logout':
return { isLoggedIn: false };
default:
const _: never = action;
return state;
}
}
/* ---------- Theme ---------- */
type Theme = 'light' | 'dark';
interface ThemeState {
readonly mode: Theme;
}
type ThemeAction = { type: 'toggle' };
const themeInitial: ThemeState = { mode: 'light' };
function themeReducer(state: ThemeState, action: ThemeAction): ThemeState {
switch (action.type) {
case 'toggle':
return { mode: state.mode === 'light' ? 'dark' : 'light' };
default:
const _: never = action;
return state;
}
}
/* ---------- 合併 Context ---------- */
interface AppContextProps {
auth: {
state: AuthState;
dispatch: React.Dispatch<AuthAction>;
};
theme: {
state: ThemeState;
dispatch: React.Dispatch<ThemeAction>;
};
}
const AppContext = createContext<AppContextProps | undefined>(undefined);
export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [authState, authDispatch] = useReducer(authReducer, authInitial);
const [themeState, themeDispatch] = useReducer(themeReducer, themeInitial);
return (
<AppContext.Provider
value={{
auth: { state: authState, dispatch: authDispatch },
theme: { state: themeState, dispatch: themeDispatch },
}}
>
{children}
</AppContext.Provider>
);
};
export const useAppContext = () => {
const ctx = useContext(AppContext);
if (!ctx) {
throw new Error('useAppContext 必須在 AppProvider 內使用');
}
return ctx;
};
說明
- 透過 分層設計,每個子模組都有自己的
state與dispatch,而AppContext僅負責把它們聚合。 - 這樣的寫法在大型專案中可避免 Context 內部的單一巨型 state,提升可讀性與維護性。
範例 4️⃣:型別安全的 Action Creator(可選)
// actions.ts
export const increment = (amount: number) => ({
type: 'increment' as const,
payload: amount,
});
export const decrement = (amount: number) => ({
type: 'decrement' as const,
payload: amount,
});
export const reset = () => ({
type: 'reset' as const,
});
在組件裡:
dispatch(increment(5)); // 完全符合 CounterAction 的型別
使用 as const 讓 TypeScript 把 type 推斷為字面量型別,配合 union 可保證 action creator 不會產生錯誤的 type。
範例 5️⃣:測試 Reducer(純函式)
// counterReducer.test.ts
import { counterReducer } from './CounterContext';
import { CounterState, CounterAction } from './CounterContext';
test('increment action', () => {
const initial: CounterState = { count: 0 };
const action: CounterAction = { type: 'increment', payload: 3 };
const newState = counterReducer(initial, action);
expect(newState.count).toBe(3);
});
test('reset action', () => {
const initial: CounterState = { count: 10 };
const action: CounterAction = { type: 'reset' };
const newState = counterReducer(initial, action);
expect(newState.count).toBe(0);
});
因為 reducer 完全 純函式,只要提供正確型別的 state 與 action,就能輕鬆寫單元測試,這也是 Context + Reducer 的另一大優勢。
常見陷阱與最佳實踐
| 陷阱 | 可能的後果 | 解決方案 / Best Practice |
|---|---|---|
把 dispatch 的型別寫成 any |
失去型別安全,執行時容易出錯 | 使用 React.Dispatch<Action>,配合 discriminated union |
在 reducer 內直接改變 state |
破壞不可變性,導致 UI 不更新或難以除錯 | 永遠回傳新物件 ({ ...state, … }) |
| 忘記在 Provider 外使用 Hook | undefined 錯誤,難以定位 |
於自訂 Hook 中加入 if (!ctx) throw new Error… |
| Context 內放太多資料 | 每次 Provider 更新都會重新渲染所有子組件 |
拆分多個 Context(如上範例)或使用 useMemo 包裹 value |
| Action payload 型別不一致 | 編譯不通過或 runtime 錯誤 | 使用 discriminated union,確保每個 type 對應唯一 payload |
Reducer 中的 default 分支忘記 never |
TypeScript 無法警告遺漏的 action | 在 default 中加入 const _: never = action; 讓編譯器檢查完整性 |
其他最佳實踐
- 將 Action、State、Reducer 分別放在獨立檔案,保持檔案職責單一。
- 使用
useCallback包裹 dispatch 包裝函式(例如increment、decrement),避免子組件因函式重新產生而不必要的重新渲染。 - 在 Provider 中使用
useMemo產生value,避免每次 state 變動都重新建立整個 context 物件。
const value = useMemo(() => ({ state, dispatch }), [state, dispatch]);
return <CounterContext.Provider value={value}>{children}</CounterContext.Provider>;
- 為大型專案加入測試: reducer、action creator、以及自訂 Hook,都可以單獨測試,提升信心。
實際應用場景
| 場景 | 為什麼選擇 Context + Reducer | 型別設計重點 |
|---|---|---|
| 使用者登入狀態 | 只需要在少數組件共享 isLoggedIn、userInfo,不必引入 Redux |
AuthState 使用 readonly,AuthAction 使用 discriminated union |
| 主題切換 (Light / Dark) | UI 只需要讀取一次,且切換行為簡單 | ThemeAction 僅有 toggle,ThemeState 用字面量型別 Theme |
| 表單暫存 (Draft) | 多個表單欄位需要在不同頁面間保持,且有「重置」或「恢復」功能 | FormState 可使用巢狀物件,FormAction 包含 updateField、reset |
| 即時聊天訊息緩存 | 訊息列表會隨時新增、刪除、更新,使用 reducer 能保證資料一致性 | MessageState 為 readonly Message[],MessageAction 包含 add、remove、update |
| 多語系切換 | 語系切換後需要重新渲染大量文字,使用 context 讓所有組件自動取得最新語系 | LocaleState 用字串型別,LocaleAction 僅有 setLocale,確保只有合法語系被傳入 |
實務提示:當全域狀態只涉及 單向流(即「讀取 → 觸發 action → 更新」),Context + Reducer 已足夠。若出現 跨模組的複雜同步,再考慮引入 Redux Toolkit 或 Recoil。
總結
- Context + Reducer 為 React 提供了一套 輕量且型別安全 的全域狀態管理方案。
- 透過 Discriminated Union、
readonly、React.Dispatch<Action>等 TypeScript 技巧,我們可以在編譯期捕捉大多數錯誤,提升開發效率與程式碼品質。 - 分層設計(多個 Context)與
useMemo/useCallback能有效避免不必要的重新渲染,讓應用在規模擴大時仍保持良好效能。 - 最後,別忘了 撰寫單元測試、使用自訂 Hook 把錯誤檢查內建於開發流程,這樣的型別設計才能真正發揮 TypeScript 的威力。
從今天開始,把上述的型別模式套用到你的 React 專案中,讓每一次的狀態變更都在編譯期得到保護,開發過程變得更順暢、維護成本更低!祝你寫程式開心 🎉