很亂呀,很亂呀
把目前已經有的值列出來:
| 資料 | 處理方式 |
|---|---|
| 訂單清單、姓名、備註 | 用 state 保留操作與輸入的結果 |
| 模擬失敗開關 | 用 state 記住使用者的選擇 |
| 送出進度與失敗原因 | 用 state 記住非同步結果 |
| 訂單筆數、總杯數、總額 | 從目前清單計算 |
例如總額屬於計算出來的值:
const totalQuantity = orders.reduce((sum, item) => sum + item.quantity, 0);
const totalPrice = orders.reduce((sum, item) => sum + item.quantity * item.price, 0);
只要更新 orders,下一次渲染會重新執行計算,不需要再記得「加杯以後也要改總額」,刪除和清空也一樣。能從目前資料得到的結果,當成渲染時的計算值,這就是衍生資料。
之所以使用衍生資料,因為今天多存一份總額,新增時記得改,刪除時忘記改,就會出現清單與總額不同的情況。
至於 const 限制的是執行中的重新指定,元件下次執行時仍然可以用新的清單算出新的總額,這類型的小清單直接計算就好,不用加 Effect 來同步另一份 state。
在專案中我們使用 status 呈現編輯中、送出中、成功與失敗,避免同時開啟互相衝突的布林值,並且旁邊還有一個 errorMessage。
以及按下送出時會清空錯誤,而失敗時會設定原因並設定 status 改成 error。
既然這兩個值都有用在同一段流程,那麼可以試著整理在一起:
// 示意片段
type SubmissionState =
| { status: "editing" }
| { status: "submitting" }
| { status: "success" }
| { status: "error"; errorMessage: string };
| 可以理解成「其中一種」,對於編輯、等待、成功只使用 status,而失敗會再多一份原因。
這是 TypeScript 的可辨識聯合型別,而我們藉由 status 知道目前資料是哪一種形狀。
現在用一個 state 保存,並且修改在事件中的送出順序:
// 示意片段
const [submission, setSubmission] = useState<SubmissionState>({ status: "editing" });
setSubmission({ status: "submitting" });
try {
await simulateSubmit(shouldFail);
setSubmission({ status: "success" });
} catch (error) {
setSubmission({
status: "error",
errorMessage: error instanceof Error ? error.message : "發生未知錯誤,請再試一次。",
});
}
useState 的 setter 會替換整個值,當重試時會換成 { status: "submitting" },舊錯誤就不會在新物件裡,這樣就不用另外呼叫 setErrorMessage(""),對於返回編輯也一樣使用 { status: "editing" } 就可以啦。
透過判斷是哪一種狀態,再顯示錯誤:
// 示意片段
{submission.status === "error" ? (
<p role="alert">{submission.errorMessage}</p>
) : null}
這樣就會在遇到錯誤狀態時,顯示 errorMessage,且如果設定 error 卻漏帶訊息,型別檢查也會提醒,但它不會幫我們安排送出順序或保證訊息不是空字串,還是要操作測試一下。![]()
整理結構是為了讓資料關係清楚,但不需要把所有 state 塞進一個物件。
TypeScript:Discriminated unions
現在如果要新增詳細檢視,通常會這樣寫:
// 錯誤的示意片段
const [selectedOrder, setSelectedOrder] = useState<OrderItem | null>(null);
按鈕觸發 setSelectedOrder(item),選到的物件裡提供杯數和規格,接著按「此筆加一杯」,透過 map() 建立新項目再放進新陣列。
這時我們會發現因為另一個 selectedOrder 的 state 還是記住原本那個物件,畫面會變成清單三杯、檢視兩杯,即使重新渲染,讀取舊物件得到的依舊是兩杯。
現在我們把需要記住的內容改成 ID,再用 ID 查找:
// 示意片段
const [selectedOrderId, setSelectedOrderId] = useState<string | null>(null);
const selectedOrder = orders.find((item) => item.id === selectedOrderId);
用 state 記住使用者選了誰,而畫面需要的杯數、甜度和金額,從目前清單查找,這樣如果原本兩杯的物件換成三杯的新物件,只要 ID 相同,下次查到的就是新內容。
在清單的 map() 裡加上檢視按鈕:
// 示意片段
<button
type="button"
aria-pressed={selectedOrderId === item.id}
onClick={() => setSelectedOrderId(item.id)}
>
檢視此筆
</button>
但是 find() 也有可能找不到,例如尚未選取或資料已被刪除,這時就要在檢視區先判斷是否存在資料,再讀取欄位。
而刪除事件除了改清單,也會處理選取 ID:
function handleDeleteOrder(id: string) {
setOrders((prevOrders) => prevOrders.filter((item) => item.id !== id));
setSelectedOrderId((prevId) => (prevId === id ? null : prevId));
}
如果刪除的是目前檢視的訂單,程式會清除選取,讓檢視區改顯示「尚未選取項目」,避免畫面繼續顯示已刪除的訂單內容。如果刪除的是其他訂單,檢視區則繼續顯示原本選取的訂單。
清空清單會移除所有訂單,因此也要清除選取,讓檢視區顯示「尚未選取項目」。
資料整理可以從五個方向檢查:
例如目前的 orders -> item -> spec 很單純,維持相同的結構就可以了:
{ ...item, spec: { ...item.spec, sweetness: "無糖" } }
但之後如果資料變成門市、日期、桌號、訂單一路包下去,修改一個杯數都要往外複製好多層,才需要再考慮用 ID 拆開關係。
對於現階段的專案,還有一個差別要注意:「待加入的杯數」與「已加入的訂單」,分別是兩個不同階段的內容,像是把兩杯紅茶加入清單後,待加入杯數會歸零,而訂單並不會歸零。
另外檢視區要求隨清單更新,所以讀同一份訂單,如果以後想做送出歷史或可取消的編輯草稿,才需要確認保存時間點與套用規則,再決定是否保留另一份資料。
刪除原本的 type SubmitStatus = ...,在元件外改為:
type SubmissionState =
| { status: "editing" }
| { status: "submitting" }
| { status: "success" }
| { status: "error"; errorMessage: string };
完整替換 DrinkOrder() 的內容。
export function DrinkOrder() {
const [orders, setOrders] = useState<OrderItem[]>([]);
const [customerName, setCustomerName] = useState("");
const [note, setNote] = useState("");
const [shouldFail, setShouldFail] = useState(false);
const [submission, setSubmission] = useState<SubmissionState>({ status: "editing" });
const [selectedOrderId, setSelectedOrderId] = useState<string | null>(null);
const totalQuantity = orders.reduce((sum, item) => sum + item.quantity, 0);
const totalPrice = orders.reduce((sum, item) => sum + item.quantity * item.price, 0);
const isLocked = submission.status === "submitting" || submission.status === "success";
const canSubmit = !isLocked && customerName.trim() !== "" && orders.length > 0;
const selectedOrder = orders.find((item) => item.id === selectedOrderId);
function handleAddOrder(newItem: OrderItem) {
setOrders((prevOrders) => [...prevOrders, newItem]);
}
function handleDeleteOrder(id: string) {
console.log("[事件] 訂單刪除", id);
setOrders((prevOrders) => prevOrders.filter((item) => item.id !== id));
setSelectedOrderId((prevId) => (prevId === id ? null : prevId));
}
function handleChangeQuantity(id: string, amount: number) {
console.log("[事件] 訂單數量變動", amount);
setOrders((prevOrders) =>
prevOrders.map((item) =>
item.id === id
? { ...item, quantity: Math.max(item.quantity + amount, 1) }
: item
)
);
}
function handleOrderSugarFree(id: string) {
setOrders((prevOrders) =>
prevOrders.map((item) =>
item.id === id
? { ...item, spec: { ...item.spec, sweetness: "無糖" } }
: item
)
);
}
function handleClearOrders() {
setOrders([]);
setSelectedOrderId(null);
}
function handleReportOrdersLater() {
console.log("[事件] 預約回報的清單", JSON.stringify(orders));
setTimeout(() => {
console.log("[延遲] 按下回報時的清單", JSON.stringify(orders));
}, 3000);
}
async function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
event.preventDefault();
if (!canSubmit) return;
// 在這次事件裡組好資料,記錄在 Console。
const submittedOrder = {
customerName: customerName.trim(),
note,
items: orders,
};
console.log("[事件] 本次模擬送出資料", JSON.stringify(submittedOrder));
setSubmission({ status: "submitting" });
try {
await simulateSubmit(shouldFail);
setSubmission({ status: "success" });
} catch (error) {
setSubmission({
status: "error",
errorMessage: error instanceof Error ? error.message : "發生未知錯誤,請再試一次。",
});
}
}
function handleBackToEdit() {
setSubmission({ status: "editing" });
}
return (
<main>
<header>
<p>第三階段 · DAY 16</p>
<h1>飲料點餐練習</h1>
<p>整理必要的 state,讓清單、檢視內容與送出流程保持一致。</p>
<Link to="/variable">比較一般變數</Link>
</header>
<form onSubmit={handleSubmit}>
<div className="order-layout">
<div>
<fieldset disabled={isLocked}>
<legend>選擇飲料</legend>
<section>
<DrinkCounter name="紅茶" price={30} onAddOrder={handleAddOrder} />
<DrinkDetails name="紅茶" description="茶香濃郁,口感醇厚。" />
</section>
<section>
<DrinkCounter name="綠茶" price={25} onAddOrder={handleAddOrder} />
<DrinkDetails name="綠茶" description="清爽茶香,適合喜歡清淡口味的人。" />
</section>
</fieldset>
</div>
<div>
<fieldset disabled={isLocked}>
<legend>編輯訂單與取餐資料</legend>
<section aria-labelledby="order-heading">
<h2 id="order-heading">訂單清單</h2>
<p>共 {orders.length} 筆、{totalQuantity} 杯,合計 {totalPrice} 元</p>
<button type="button" onClick={handleClearOrders} disabled={orders.length === 0}>
清空訂單清單
</button>
<button type="button" onClick={handleReportOrdersLater}>
3 秒後回報訂單清單
</button>
{orders.length === 0 ? (
<p>還沒有訂單項目,先選杯數與規格,再加入清單。</p>
) : (
<ul>
{orders.map((item) => (
<li key={item.id} data-order-id={item.id}>
<h3>{item.name}</h3>
<p>規格:{item.spec.sweetness}/{item.spec.ice}</p>
<p>{item.quantity} 杯 × {item.price} 元,小計 {item.quantity * item.price} 元</p>
<button
type="button"
aria-pressed={selectedOrderId === item.id}
onClick={() => setSelectedOrderId(item.id)}
>
檢視此筆
</button>
<button type="button" onClick={() => handleChangeQuantity(item.id, 1)}>
此筆加一杯
</button>
<button type="button" onClick={() => handleChangeQuantity(item.id, -1)}
disabled={item.quantity === 1}>
此筆減一杯
</button>
<button type="button" onClick={() => handleOrderSugarFree(item.id)}>
此筆改無糖
</button>
<button type="button" onClick={() => handleDeleteOrder(item.id)}>
刪除此筆
</button>
</li>
))}
</ul>
)}
</section>
<section aria-labelledby="selected-heading">
<h2 id="selected-heading">目前檢視的項目</h2>
{selectedOrder ? (
<div>
<p>{selectedOrder.name},{selectedOrder.quantity} 杯</p>
<p>規格:{selectedOrder.spec.sweetness}/{selectedOrder.spec.ice}</p>
<p>小計:{selectedOrder.quantity * selectedOrder.price} 元</p>
<button type="button" onClick={() => setSelectedOrderId(null)}>
取消檢視
</button>
</div>
) : (
<p>尚未選取項目,請按清單裡的「檢視此筆」。</p>
)}
</section>
<section aria-labelledby="pickup-heading">
<h2 id="pickup-heading">取餐資料</h2>
<div>
<label htmlFor="customer-name">取餐姓名(必填)</label>
<input
id="customer-name"
name="customerName"
type="text"
required
value={customerName}
onChange={(event) => setCustomerName(event.target.value)}
/>
</div>
<div>
<label htmlFor="order-note">整筆訂單備註(選填)</label>
<textarea
id="order-note"
name="note"
rows={3}
value={note}
onChange={(event) => setNote(event.target.value)}
/>
</div>
<p>即時摘要:{customerName.trim() || "尚未填姓名"},備註:{note || "無"}</p>
<label>
<input
type="checkbox"
checked={shouldFail}
onChange={(event) => setShouldFail(event.target.checked)}
/>
模擬這次送出失敗
</label>
<p>請填寫姓名並至少加入一筆訂單;上方尚未加入的杯數不會送出。</p>
<button type="submit" disabled={!canSubmit}>
{submission.status === "submitting"
? "模擬送出中…"
: submission.status === "error" ? "重新模擬送出" : "模擬送出訂單"}
</button>
</section>
</fieldset>
<div role="status" aria-live="polite">
{submission.status === "submitting" ? <p>正在模擬送出,請稍候…</p> : null}
{submission.status === "success" ? (
<p>
模擬成功!{customerName.trim()} 的訂單共 {orders.length} 筆、
{totalQuantity} 杯,合計 {totalPrice} 元。
</p>
) : null}
</div>
{submission.status === "error" ? <p role="alert">{submission.errorMessage}</p> : null}
{submission.status === "success" ? (
<button type="button" onClick={handleBackToEdit}>返回編輯</button>
) : null}
</div>
</div>
</form>
</main>
);
}
下面說明幾個重點:
find() 就會找到目前的內容。submittedOrder 是當次事件的區域資料,成功畫面使用目前清單顯示摘要,因此送出中與成功後都停用編輯,確保摘要與送出內容一致。現在操作三個動作:
加入兩杯紅茶並選取,再按清單的「此筆加一杯」
這裡是確認清單與檢視區使用同一份最新資料,避免內容不同步。
刪除目前選取的紅茶
除了事件觸發,也同時確認檢視區沒有看到已刪除的訂單。
這一步主要是確認送出狀態可以正確切換,保留訂單資料並清除舊錯誤。
明天接著把畫面拆成合作的元件,來看看兩個元件要用同一份資料時,state 應該放在哪裡,以及資料和事件要怎麼傳吧~