本文 AI 產出,尚未審核

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. 基本流程

  1. 定義 State 型別 – 描述全域狀態的結構。
  2. 定義 Action 型別 – 使用 discriminated union 讓 TypeScript 能分辨每個 action。
  3. 實作 Reducer – 完全純函式,根據 action 變更 state。
  4. 建立 Context – 包含 statedispatch,兩者皆需型別。
  5. 提供 Provider – 在根組件包住子樹,傳遞已型別化的 context。
  6. 在子組件使用 – 透過 useContext 取得 statedispatch,並在 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 包住應用的根節點,提供型別安全的 statedispatch
  • useCounter Hook 把 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;
};

說明

  • 透過 分層設計,每個子模組都有自己的 statedispatch,而 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 完全 純函式,只要提供正確型別的 stateaction,就能輕鬆寫單元測試,這也是 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; 讓編譯器檢查完整性

其他最佳實踐

  1. 將 Action、State、Reducer 分別放在獨立檔案,保持檔案職責單一。
  2. 使用 useCallback 包裹 dispatch 包裝函式(例如 incrementdecrement),避免子組件因函式重新產生而不必要的重新渲染。
  3. 在 Provider 中使用 useMemo 產生 value,避免每次 state 變動都重新建立整個 context 物件。
const value = useMemo(() => ({ state, dispatch }), [state, dispatch]);
return <CounterContext.Provider value={value}>{children}</CounterContext.Provider>;
  1. 為大型專案加入測試: reducer、action creator、以及自訂 Hook,都可以單獨測試,提升信心。

實際應用場景

場景 為什麼選擇 Context + Reducer 型別設計重點
使用者登入狀態 只需要在少數組件共享 isLoggedInuserInfo,不必引入 Redux AuthState 使用 readonlyAuthAction 使用 discriminated union
主題切換 (Light / Dark) UI 只需要讀取一次,且切換行為簡單 ThemeAction 僅有 toggleThemeState 用字面量型別 Theme
表單暫存 (Draft) 多個表單欄位需要在不同頁面間保持,且有「重置」或「恢復」功能 FormState 可使用巢狀物件,FormAction 包含 updateFieldreset
即時聊天訊息緩存 訊息列表會隨時新增、刪除、更新,使用 reducer 能保證資料一致性 MessageStatereadonly Message[]MessageAction 包含 addremoveupdate
多語系切換 語系切換後需要重新渲染大量文字,使用 context 讓所有組件自動取得最新語系 LocaleState 用字串型別,LocaleAction 僅有 setLocale,確保只有合法語系被傳入

實務提示:當全域狀態只涉及 單向流(即「讀取 → 觸發 action → 更新」),Context + Reducer 已足夠。若出現 跨模組的複雜同步,再考慮引入 Redux Toolkit 或 Recoil。


總結

  • Context + Reducer 為 React 提供了一套 輕量且型別安全 的全域狀態管理方案。
  • 透過 Discriminated UnionreadonlyReact.Dispatch<Action> 等 TypeScript 技巧,我們可以在編譯期捕捉大多數錯誤,提升開發效率與程式碼品質。
  • 分層設計(多個 Context)與 useMemo/useCallback 能有效避免不必要的重新渲染,讓應用在規模擴大時仍保持良好效能。
  • 最後,別忘了 撰寫單元測試使用自訂 Hook 把錯誤檢查內建於開發流程,這樣的型別設計才能真正發揮 TypeScript 的威力。

從今天開始,把上述的型別模式套用到你的 React 專案中,讓每一次的狀態變更都在編譯期得到保護,開發過程變得更順暢、維護成本更低!祝你寫程式開心 🎉