打工日常:A 說 B 不對,B 說 C 教的,C 說 A 教的(完美閉環)
回頭看飲料訂單專案,從新增、刪除、改杯數到改規格,都有各自的事件函式且常常出現 setOrders、map() 和 filter(),假設操作動作變多,當想確認「杯數最低是多少」或「改無糖時冰塊會不會一起改」,就要找到對應的事件函式。
現在我們用 reducer,把這些訂單更新規則放在同一個地方。
reducer 其實是我們撰寫的函式,不是什麼 JavaScript 內建的函式名稱,會接收「目前 state」與「操作描述 action」,再回傳「下一份 state」,簡單地說就是依照操作描述(action),去做對應的 state 處理,完成後回傳 state 結果。
對於這類型的函式所扮演的角色,我們叫做 **reducer **,而 React 提供 useReducer Hook,讓我們可以把寫好的 ordersReducer 傳給它,React 使用這個函式管理訂單 state。
前面說到會依操作描述做對應的處理,以「A 訂單加一杯」為例,action 會記錄 A 的 ID 與增加一杯這件事。
事件函式會透過 dispatch 把 action 交給 React,再由 reducer 找出 A、計算新杯數,最後用新的訂單更新畫面。
| 角色 | 行為 |
|---|---|
| 事件函式 | 確認可以操作,再送出 A 加一杯的 action |
| action | 記錄操作種類、A 的 ID 與增加數量 |
dispatch |
把 action 交給 React 排入更新 |
ordersReducer |
依目前訂單與 action,回傳下一份訂單 |
整體就會是「操作 -> action -> reducer -> 新 state -> 畫面」
訂單的更新規則集中後,事件函式只要描述這次對訂單做了什麼操作
想確認減杯下限,就看改杯數的分支。想確認改甜度如何保留冰塊,就看改規格的分支。
方便查找問題與測試
發生錯誤,可以直接看 action 的 ID 與數量是否正確,檢查 reducer 的計算,因為 reducer 是函式,也能直接放入固定資料測試,確認舊訂單沒被修改,其他項目也沒有被誤改。
使用 reducer 要多寫 action、dispatch 與對應分支。從按鈕追到資料更新,也會經過事件函式和 reducer 兩個地方。只有簡單加減杯時,原本的 useState 通常更直觀。
另外,若把姓名、草稿、訂單和送出流程全部塞進同一個 reducer,分支一多仍然會難維護,所以 reducer 必須保持純粹,API 請求與計時器也要留在外面,不能把整個非同步流程都搬進去。
React:Comparing useState and useReducer
React:Writing reducers well
判斷方式就是同一份資料的更新規則,是否已經多到需要集中整理。
例如飲料訂單專案中,訂單有新增、刪除、改杯數與改規格等操作,且都是對同一個資料操作,那可以進一步集中整理,但姓名、備註與局部草稿是操作不同的資料,則繼續使用 useState。
在 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 檢查這個物件的形狀。
在 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 能協助檢查是否漏傳欄位。
接著把原本事件函式裡的杯數計算移到 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 下一份陣列。
現在我們匯入 useReducer,同時保留其他元件還在使用的 useState:
import { useReducer, useState, type SubmitEvent } from "react";
在 DrinkOrder 裡,把原本的:
const [orders, setOrders] = useState<OrderItem[]>([]);
替換成:
const [orders, dispatch] = useReducer(ordersReducer, []);
這個 Hook 要放在元件最上層
orders 是這次渲染使用的資料。dispatch 用來送 action。以「改杯數」在 DrinkOrder 內為範例,同名函式並整段替換,訂單更新改成 dispatch,選取 ID 由原本的 setter 處理:
function handleChangeQuantity(id: string, amount: number) {
if (isLocked) return;
dispatch({ type: "quantity_changed", id, amount });
}
保持純粹性,在 reducer 裡只做資料計算,不要在 reducer 裡呼叫其他 setter、發送請求,或修改傳入的訂單物件。
要注意在開發模式的 StrictMode 可能額外執行一次 reducer 檢查純粹性。
選 A,按一次「此筆加一杯」,預期 A 變 3 杯,清單與檢視一起更新,合計 4 杯、115 元。再連按兩次「此筆減一杯」,預期 A 剩 1 杯,減杯按鈕停用,合計 2 杯、55 元。
觀察更新是否只影響 A,以及杯數下限、清單、檢視和合計是否有錯誤。

把 A 的草稿改成無糖去冰,先不套用,清單與檢視仍是半糖少冰,再按「套用規格」,兩處才一起變成無糖去冰,而 B 維持全糖正常冰,合計仍是 55 元。
觀察草稿是否仍要按套用才寫入訂單,以及修改 A 時是否保留 B 的資料。


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

總是會有遲來的靈光一閃。
假設:「這張全部改無糖,冰塊照原本的。」
那麼畫面只要加入一個按鈕,用一個 action 修改所有已保存訂單,保留 ID、名稱、單價、杯數與各自的冰塊。
在 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:
OrderListProps 加上 onAllSugarFree: () => void;。OrderList 函式的 props 解構中補上 onAllSugarFree。接著在「清空訂單清單」按鈕後新增:
<button type="button" onClick={onAllSugarFree} disabled={orders.length === 0}>
整張訂單改無糖
</button>
最後在 DrinkOrder 渲染 <OrderList ... /> 的 props 裡加上:
onAllSugarFree={handleAllSugarFree}
重新載入並準備最初的 A、B,選 A,把草稿改全糖去冰不套用,按一次「整張訂單改無糖」,觀察兩個重點:

今天其實算是小重構,讓規則集中管理,方便維護的時候找到問題點,也可以針對單獨的功能做單元測試。
明天接著用顯示設定的小範例認識 Context,看看資料跨越多層元件時,可以由哪裡提供、讓哪些內層元件讀取。
將 drink-order.tsx 第一行的 React 匯入替換為:
import { useReducer, useState, type SubmitEvent } from "react";
在 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("未知的訂單操作");
}
}
}
在 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}