iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
自我挑戰組

React 入門到實作與除錯|30 天哩ㄟ刻系列 第 19 篇

Day 19 : 規則集中,reducer 整理訂單邏輯

  • 分享至 

  • xImage
  •  

打工日常:A 說 B 不對,B 說 C 教的,C 說 A 教的(完美閉環)

今天目標

  1. 認識 reducer
  2. 比較 reducer 的優缺點
  3. 用 reducer 整理訂單
  4. 執行觀察
  5. 整張訂單改無糖

認識 reducer

訂單操作愈來愈多

回頭看飲料訂單專案,從新增、刪除、改杯數到改規格,都有各自的事件函式且常常出現 setOrders、map() 和 filter(),假設操作動作變多,當想確認「杯數最低是多少」或「改無糖時冰塊會不會一起改」,就要找到對應的事件函式。

現在我們用 reducer,把這些訂單更新規則放在同一個地方。

reducer 是什麼

reducer 其實是我們撰寫的函式,不是什麼 JavaScript 內建的函式名稱,會接收「目前 state」與「操作描述 action」,再回傳「下一份 state」,簡單地說就是依照操作描述(action),去做對應的 state 處理,完成後回傳 state 結果。

對於這類型的函式所扮演的角色,我們叫做 **reducer **,而 React 提供 useReducer Hook,讓我們可以把寫好的 ordersReducer 傳給它,React 使用這個函式管理訂單 state。

React:Extracting State Logic into a Reducer

用 action 描述操作

前面說到會依操作描述做對應的處理,以「A 訂單加一杯」為例,action 會記錄 A 的 ID 與增加一杯這件事。

事件函式會透過 dispatch 把 action 交給 React,再由 reducer 找出 A、計算新杯數,最後用新的訂單更新畫面。

角色 行為
事件函式 確認可以操作,再送出 A 加一杯的 action
action 記錄操作種類、A 的 ID 與增加數量
dispatch 把 action 交給 React 排入更新
ordersReducer 依目前訂單與 action,回傳下一份訂單

整體就會是「操作 -> action -> reducer -> 新 state -> 畫面」

比較 reducer 的優缺點

規則集中,方便查找與測試

  • 訂單的更新規則集中後,事件函式只要描述這次對訂單做了什麼操作
    想確認減杯下限,就看改杯數的分支。想確認改甜度如何保留冰塊,就看改規格的分支。

  • 方便查找問題與測試
    發生錯誤,可以直接看 action 的 ID 與數量是否正確,檢查 reducer 的計算,因為 reducer 是函式,也能直接放入固定資料測試,確認舊訂單沒被修改,其他項目也沒有被誤改。

程式碼增加,閱讀要多追一步

使用 reducer 要多寫 action、dispatch 與對應分支。從按鈕追到資料更新,也會經過事件函式和 reducer 兩個地方。只有簡單加減杯時,原本的 useState 通常更直觀。

另外,若把姓名、草稿、訂單和送出流程全部塞進同一個 reducer,分支一多仍然會難維護,所以 reducer 必須保持純粹,API 請求與計時器也要留在外面,不能把整個非同步流程都搬進去。

React:Comparing useState and useReducer
React:Writing reducers well

依更新規則選擇使用方式

判斷方式就是同一份資料的更新規則,是否已經多到需要集中整理。

例如飲料訂單專案中,訂單有新增、刪除、改杯數與改規格等操作,且都是對同一個資料操作,那可以進一步集中整理,但姓名、備註與局部草稿是操作不同的資料,則繼續使用 useState。

用 reducer 整理訂單

整理已保存訂單

在 drink-order.tsx 的外層元件 DrinkOrder,把已加入清單的 orders 訂單陣列改成由 useReducer 管理。

首先集中新增、刪除、改杯數、改無糖、一鍵無糖去冰、套用草稿與清空這七種更新,這些操作都會修改同一份訂單,適合放在一起檢視規則:

資料 修改 原因
orders 從 useState 換成 useReducer 多個操作會修改同一份訂單陣列
selectedOrderId 保留 useState 只保存選取 ID,刪除與清空時由事件函式一起處理
selectedOrder、總杯數、總額、平均單杯金額 繼續從目前訂單計算 可以算出的資料不另外存一份
OrderEditor 的 draftSpec 保留局部 useState 尚未套用的編輯內容可以與訂單不同
待加入杯數與規格、姓名、備註、介紹開合、送出狀態 維持原本的 useState 操作不同的資料,先不動

從加杯事件看分工

依照前面的分工對照現有元件,以「此筆加一杯」會經過的位置來看,就會是這四個步驟:

OrderList 按下「此筆加一杯」
  -> 呼叫 onChangeQuantity(id, 1)
  -> DrinkOrder 的事件函式 dispatch 一個 action
  -> ordersReducer(目前訂單, action) 回傳下一份訂單
  -> React 使用新的 orders 渲染清單與檢視

來試著寫一個數量變化的 action 函式:

function handleChangeQuantity(id: string, amount: number) {
  if (isLocked) return;
  dispatch({ type: "quantity_changed", id, amount });
}

對照這份 action:

  • type 表示操作
  • id 表示對象
  • amount 表示增加或減少多少
  • quantity_changed 是我們依操作意義取的名稱

接下來用 TypeScript 檢查這個物件的形狀。

定義訂單的 action

在 OrderItem 型別後新增 action 型別:

type OrderAction =
  | { type: "order_added"; item: OrderItem }
  | { type: "order_deleted"; id: string }
  | { type: "quantity_changed"; id: string; amount: number }
  | { type: "sugar_free_requested"; id: string }
  | { type: "preset_applied"; id: string }
  | { type: "spec_applied"; id: string; spec: DrinkSpec }
  | { type: "orders_cleared" };

每種 action 只帶需要的資料。例如刪除只需要 ID,套用草稿需要完整規格,清空不需要某一筆的資料。這樣 TypeScript 能協助檢查是否漏傳欄位。

把更新規則放進 reducer

接著把原本事件函式裡的杯數計算移到 ordersReducer 內,以「改杯數」來看:

// ordersReducer 的 switch 內片段。
case "quantity_changed": {
  return orders.map((item) =>
    item.id === action.id
      ? { ...item, quantity: Math.max(item.quantity + action.amount, 1) }
      : item
  );
}

我們用 switch...case 根據 ID 找出指定項目,完成只改杯數,且至少保留一杯的功能,對於不可變更新的規則,換到 reducer 裡還是一樣要遵守。

依照這個邏輯,新增使用展開語法,刪除使用 filter(),改規格使用 map(),對於每個 case 都 return 下一份陣列。

React:Extracting State Logic into a Reducer

用 useReducer 接上訂單

現在我們匯入 useReducer,同時保留其他元件還在使用的 useState:

import { useReducer, useState, type SubmitEvent } from "react";

在 DrinkOrder 裡,把原本的:

const [orders, setOrders] = useState<OrderItem[]>([]);

替換成:

const [orders, dispatch] = useReducer(ordersReducer, []);

這個 Hook 要放在元件最上層

  • 第一個參數是 reducer 函式本身。
  • 第二個參數是初始 state 空訂單陣列。
  • 回傳的 orders 是這次渲染使用的資料。
  • dispatch 用來送 action。

React:useReducer

在事件函式送出 action

以「改杯數」在 DrinkOrder 內為範例,同名函式並整段替換,訂單更新改成 dispatch,選取 ID 由原本的 setter 處理:

function handleChangeQuantity(id: string, amount: number) {
  if (isLocked) return;
  dispatch({ type: "quantity_changed", id, amount });
}

reducer 只負責計算資料

保持純粹性,在 reducer 裡只做資料計算,不要在 reducer 裡呼叫其他 setter、發送請求,或修改傳入的訂單物件。

要注意在開發模式的 StrictMode 可能額外執行一次 reducer 檢查純粹性。

React:useReducer troubleshooting

執行觀察

準備兩筆不同規格的訂單

  • 紅茶 A:2 杯、半糖少冰、單價 30 元
  • 綠茶 B:1 杯、全糖正常冰、單價 25 元
  • 初始合計 3 杯、85 元。

加減杯數

選 A,按一次「此筆加一杯」,預期 A 變 3 杯,清單與檢視一起更新,合計 4 杯、115 元。再連按兩次「此筆減一杯」,預期 A 剩 1 杯,減杯按鈕停用,合計 2 杯、55 元。

觀察更新是否只影響 A,以及杯數下限、清單、檢視和合計是否有錯誤。

https://ithelp.ithome.com.tw/upload/images/20261003/201843321RklLpWDAX.png

套用規格

把 A 的草稿改成無糖去冰,先不套用,清單與檢視仍是半糖少冰,再按「套用規格」,兩處才一起變成無糖去冰,而 B 維持全糖正常冰,合計仍是 55 元。

觀察草稿是否仍要按套用才寫入訂單,以及修改 A 時是否保留 B 的資料。

https://ithelp.ithome.com.tw/upload/images/20261003/20184332MXAUnNrcqP.png

https://ithelp.ithome.com.tw/upload/images/20261003/201843328FJo7ehpMz.png

刪除選取訂單

保持選取 A,刪除 A,預期清單只剩 B,合計 1 杯、25 元;檢視顯示尚未選取項目,編輯區移除。

觀察刪除訂單時,是否有取消選取。

https://ithelp.ithome.com.tw/upload/images/20261003/20184332yVCxkrwisv.png

整張訂單改無糖

總是會有遲來的靈光一閃。

假設:「這張全部改無糖,冰塊照原本的。」

那麼畫面只要加入一個按鈕,用一個 action 修改所有已保存訂單,保留 ID、名稱、單價、杯數與各自的冰塊。

新增整張改無糖的 action

在 OrderAction 新增:

  | { type: "orders_cleared" }
  | { type: "all_sugar_free_requested" };

保留每筆原本的冰塊

在 ordersReducer 的 default 前新增,只改甜度,保留各自的冰塊:

case "all_sugar_free_requested": {
  return orders.map((item) => ({
    ...item,
    spec: { ...item.spec, sweetness: "無糖" },
  }));
}

接上整張改無糖按鈕

在 DrinkOrder 的事件函式新增:

function handleAllSugarFree() {
  if (isLocked) return;
  dispatch({ type: "all_sugar_free_requested" });
}

修改 props:

  1. OrderListProps 加上 onAllSugarFree: () => void;。
  2. OrderList 函式的 props 解構中補上 onAllSugarFree。

接著在「清空訂單清單」按鈕後新增:

<button type="button" onClick={onAllSugarFree} disabled={orders.length === 0}>
  整張訂單改無糖
</button>

最後在 DrinkOrder 渲染 <OrderList ... /> 的 props 裡加上:

onAllSugarFree={handleAllSugarFree}

測看看改無糖後,草稿仍保留

重新載入並準備最初的 A、B,選 A,把草稿改全糖去冰不套用,按一次「整張訂單改無糖」,觀察兩個重點:

https://ithelp.ithome.com.tw/upload/images/20261003/20184332fOoTYItLHf.png

  • A 變無糖少冰,B 變無糖正常冰;杯數與金額不變,合計仍為 3 杯、85 元。
  • A 的未套用草稿仍是全糖去冰,沒有被這次操作覆蓋。

今天其實算是小重構,讓規則集中管理,方便維護的時候找到問題點,也可以針對單獨的功能做單元測試。

明天接著用顯示設定的小範例認識 Context,看看資料跨越多層元件時,可以由哪裡提供、讓哪些內層元件讀取。

參考資料

附錄修改片段

匯入 useReducer

將 drink-order.tsx 第一行的 React 匯入替換為:

import { useReducer, useState, type SubmitEvent } from "react";

新增 action 與 reducer

在 OrderItem 型別後、DrinkNameProps 前新增以下內容,放在元件函式外:

type OrderAction =
  | { type: "order_added"; item: OrderItem }
  | { type: "order_deleted"; id: string }
  | { type: "quantity_changed"; id: string; amount: number }
  | { type: "sugar_free_requested"; id: string }
  | { type: "preset_applied"; id: string }
  | { type: "spec_applied"; id: string; spec: DrinkSpec }
  | { type: "orders_cleared" };

function ordersReducer(orders: OrderItem[], action: OrderAction): OrderItem[] {
  switch (action.type) {
    case "order_added": {
      return [...orders, action.item];
    }
    case "order_deleted": {
      return orders.filter((item) => item.id !== action.id);
    }
    case "quantity_changed": {
      return orders.map((item) =>
        item.id === action.id
          ? { ...item, quantity: Math.max(item.quantity + action.amount, 1) }
          : item
      );
    }
    case "sugar_free_requested": {
      return orders.map((item) =>
        item.id === action.id
          ? { ...item, spec: { ...item.spec, sweetness: "無糖" } }
          : item
      );
    }
    case "preset_applied": {
      return orders.map((item) =>
        item.id === action.id
          ? { ...item, spec: { ...item.spec, sweetness: "無糖", ice: "去冰" } }
          : item
      );
    }
    case "spec_applied": {
      return orders.map((item) =>
        item.id === action.id ? { ...item, spec: { ...action.spec } } : item
      );
    }
    case "orders_cleared": {
      return [];
    }
    default: {
      throw new Error("未知的訂單操作");
    }
  }
}

接上訂單 state

在 DrinkOrder 中,將 const [orders, setOrders] = useState<OrderItem[]>([]); 替換為:

const [orders, dispatch] = useReducer(ordersReducer, []);

替換訂單事件

在 DrinkOrder 中,逐一找到以下七個同名函式各自整段替換:

function handleAddOrder(newItem: OrderItem) {
  if (isLocked) return;
  dispatch({ type: "order_added", item: newItem });
}

function handleDeleteOrder(id: string) {
  if (isLocked) return;
  dispatch({ type: "order_deleted", id });
  setSelectedOrderId((prevId) => (prevId === id ? null : prevId));
}

function handleChangeQuantity(id: string, amount: number) {
  if (isLocked) return;
  dispatch({ type: "quantity_changed", id, amount });
}

function handleOrderSugarFree(id: string) {
  if (isLocked) return;
  dispatch({ type: "sugar_free_requested", id });
}

function handleOrderPreset(id: string) {
  if (isLocked) return;
  dispatch({ type: "preset_applied", id });
}

function handleApplySpec(id: string, spec: DrinkSpec) {
  if (isLocked) return;
  dispatch({ type: "spec_applied", id, spec });
}

function handleClearOrders() {
  if (isLocked) return;
  dispatch({ type: "orders_cleared" });
  setSelectedOrderId(null);
}

新增整張改無糖

完成原有操作的測試後,再加入這個功能。

在 OrderAction 最後一列新增:

| { type: "orders_cleared" }
| { type: "all_sugar_free_requested" };

在 ordersReducer 的 default 前新增分支:

case "all_sugar_free_requested": {
  return orders.map((item) => ({
    ...item,
    spec: { ...item.spec, sweetness: "無糖" },
  }));
}

在 DrinkOrder 的 handleClearOrders 後新增事件函式:

function handleAllSugarFree() {
  if (isLocked) return;
  dispatch({ type: "all_sugar_free_requested" });
}

在 OrderListProps 的 onClear 後新增欄位:

onAllSugarFree: () => void;

在 OrderList 的 props 解構中,將包含 onSelect 的那一行替換為:

onSelect, onChangeQuantity, onSugarFree, onDelete, onClear, onAllSugarFree, onReportLater,

在 OrderList 的「清空訂單清單」按鈕後新增:

<button type="button" onClick={onAllSugarFree} disabled={orders.length === 0}>
  整張訂單改無糖
</button>

最後,在 DrinkOrder 渲染 <OrderList ... /> 的 onClear 屬性後補上:

onAllSugarFree={handleAllSugarFree}

上一篇
Day 18 : 留還是不留,理解 state 的保留與重設
系列文
React 入門到實作與除錯|30 天哩ㄟ刻 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言