俗擱有力,簡單就是王道
今天要沿用訂單,加上取餐姓名和訂單備註,並且讓畫面能顯示訂單的狀態,這樣客人就可以看到自己訂單好了沒~
可以先來想畫面的內容會對應什麼狀態:
| 畫面 | 是否能編輯 | 送出按鈕 | 狀態顯示 |
|---|---|---|---|
| 還沒填好 | 可以 | 停用 | 提醒填姓名、加入項目 |
| 已經填好 | 可以 | 可送出 | 姓名、備註與訂單摘要 |
| 等待結果 | 不可以 | 停用 | 模擬送出中 |
| 成功 | 先保持鎖定 | 停用 | 成功摘要、返回編輯按鈕 |
| 失敗 | 可以 | 資料有效時可重試 | 上一次失敗的原因 |
上面有五種畫面情況,其中「還沒填好」與「已經填好」都還在編輯,只需要檢查姓名和清單,就知道送出按鈕能不能用,那麼可以先把這兩個歸類在編輯中,這樣狀態會有四種:
// 型別放在元件外面。
type SubmitStatus = "editing" | "submitting" | "success" | "error";
// state 放在 DrinkOrder 的最上層。
const [status, setStatus] = useState<SubmitStatus>("editing");
這裡要限定 status 同一時間就只會是其中一個階段,否則若分別用 isSubmitting 和 isSuccess,一不小心兩個都設成 true,畫面就會同時說「請稍候」和「成功了」。
現在把變化原因接上去:
| 目前狀態 | 事件 | 下一個狀態 |
|---|---|---|
editing 或 error |
使用者送出有效資料 | submitting |
submitting |
模擬工作完成 | success |
submitting |
模擬工作失敗 | error |
success |
使用者按返回編輯 | editing |
前幾天的練習都有一個備註輸入框,只有 <input>,沒有把文字放進 React state。是我們拿來觀察畫面更新後,瀏覽器能不能保留原本的輸入,現在加上取餐姓名顯示在摘要上:
// 示意片段,放 DrinkOrder 內
const [customerName, setCustomerName] = useState("");
// 放 JSX 內
<label htmlFor="customer-name">取餐姓名(必填)</label>
<input
id="customer-name"
type="text"
value={customerName}
onChange={(event) => setCustomerName(event.target.value)}
/>
value={customerName} 表示這個輸入框要顯示 state 裡的文字。
使用者輸入時,onChange 讀到 event.target.value,再透過 setCustomerName 更新 state,下一次渲染,就把新的文字交回 value。
這就是受控輸入(controlled input),表示輸入框的值由 React state 控制,輸入事件則負責更新它。
初始值使用 "",從一開始就讓 value 是字串。
label 的 htmlFor 對應輸入框的 id,讓姓名欄位有明確的標籤,也能透過點選標籤找到輸入位置。
整筆訂單備註也採用相同方式,只是換成可以輸入多行的 <textarea>:
// state 與 JSX 分別放在對應位置。
const [note, setNote] = useState("");
<textarea
value={note}
onChange={(event) => setNote(event.target.value)}
/>
React 的 <textarea> 同樣用 value 和 onChange 控制。
這份 note 是整筆訂單的備註,與紅茶、綠茶各自原本的練習備註分開,新加的整筆備註會在後面被放進模擬送出的資料中。
為了讓成功和失敗我們都能測試,加一個「模擬這次送出失敗」的核取方塊:
// state 與 JSX 分別放在對應位置。
const [shouldFail, setShouldFail] = useState(false);
<input
type="checkbox"
checked={shouldFail}
onChange={(event) => setShouldFail(event.target.checked)}
/>
這裡記住的是有沒有勾選,所以使用 checked,事件裡也讀 event.target.checked。文字讀 value,勾選狀態讀 checked,兩個不要混在一起。
欄位準備好了,接著把送出的動作放在 <form>:
// 示意片段
<form onSubmit={handleSubmit}>
{/* 姓名、備註與訂單內容 */}
<button type="submit">模擬送出訂單</button>
</form>
送出流程寫在表單的 onSubmit,當按下送出按鈕或在姓名輸入框按 Enter,都能進入同一個處理函式,不必在按鈕 onClick 再寫一份。
處理函式一開始要呼叫 event.preventDefault(),阻止瀏覽器原本的表單送出導覽,讓目前的頁面繼續處理這次互動。
還有一個容易漏掉的地方:加杯、刪除和清空按鈕,現在都放進表單裡了,這些按鈕要繼續保留 type="button",只有送出訂單才使用 type="submit",否則按加杯也可能變成送出。
接著在元件外準備一個模擬動作,呼叫後會等待,再依照 shouldFail 決定成功或失敗:
// 示意片段
function simulateSubmit(shouldFail: boolean): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
reject(new Error("模擬送出失敗,請取消勾選失敗選項後重試。"));
} else {
resolve();
}
}, 1500);
});
}
Promise 代表一個等待結果的工作。這裡呼叫 resolve() 表示完成,呼叫 reject() 則帶出失敗原因;Promise<void> 表示成功時沒有另外回傳資料。
1500 是約 1.5 秒的計時設定,讓我們有時間觀察等待畫面。
simulateSubmit 會負責模擬工作成功或失敗,送出事件則根據結果更新 status 與錯誤訊息,讓畫面跟著改變。
接著把上面的模擬動作放進事件處理函式 handleSubmit(),為了方便觀察錯誤訊息,我們多建立了errorMessage,並且用 state 保存錯誤字串:
// 示意片段:放在 async 事件處理函式 handleSubmit() 中。
setErrorMessage("");
setStatus("submitting");
try {
await simulateSubmit(shouldFail);
setStatus("success");
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "發生未知錯誤,請再試一次。");
setStatus("error");
}
上面的片段會先清除之前的錯誤訊息,將狀態切換到送出中,再開始等待,成功後往下執行 setStatus("success"),失敗則進入 catch,保存錯誤文字並切換到 error。
對於 error instanceof Error 是先確認拿到的是 Error 物件,再讀取它的 message,如果不是,就顯示預備的錯誤訊息。
await 暫停的是這個 async 函式後續的工作,瀏覽器仍能呈現等待畫面,但要改變畫面就需要明確呼叫 setter。
因為 simulateSubmit 這份工作是使用者觸發 handleSubmit 時才動作,一般輸入姓名或元件重新渲染時不用自動啟動,所以我們將它放在送出事件裡,才不會修改姓名和增減杯數就直接執行模擬。
流程有了,接著讓它控制按鈕與輸入框,首先計算兩個值:
// 在 DrinkOrder 裡,每次渲染直接計算。
const isLocked = status === "submitting" || status === "success";
const canSubmit = !isLocked && customerName.trim() !== "" && orders.length > 0;
送出中以及成功時,isLocked 都是 true,其餘時間還需要確認姓名不是空白、清單至少一筆,canSubmit 才會是 true。
trim() 會去掉前後空白,避免輸入三個空白也算填了姓名,輸入時會保留原本的文字,只在判斷與摘要使用 trim(),不用每打一個字就改寫內容。
這兩個值都能從現有 state 算出,不需要再建立 setter 讓每次操作都跟著同步。
接著整合會用 <fieldset disabled={isLocked}> 包住飲料、清單與取餐資料,送出中不只停用送出按鈕,也停用裡面的輸入、加杯和刪除,讓成功摘要對應同一份資料,這裡停用的是表單控制項,頁面的既有連結不會因此失效。
legend 只放這組欄位的標題;「返回編輯」按鈕則放在這個 fieldset 外面,才不會成功後連恢復操作的按鈕也一起鎖住。
現在把表單接回昨天的點餐畫面,一些昨天已建立的內容我們沿用,其他修改參考下面的程式:
把原本的 import { useState } from "react"; 換成:
import { useState, type SubmitEvent } from "react";
SubmitEvent<HTMLFormElement> 是下面表單送出事件的 React 型別,HTMLFormElement 指定處理事件的元素是表單。這裡的 SubmitEvent 要從 "react" 匯入,type 表示只匯入型別。
下面兩個定義放在所有元件外面,只新增一次:
type SubmitStatus = "editing" | "submitting" | "success" | "error";
function simulateSubmit(shouldFail: boolean): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
reject(new Error("模擬送出失敗,請取消勾選失敗選項後重試。"));
} else {
resolve();
}
}, 1500);
});
}
export function DrinkOrder() {
const [orders, setOrders] = useState<OrderItem[]>([]);
const [customerName, setCustomerName] = useState("");
const [note, setNote] = useState("");
const [shouldFail, setShouldFail] = useState(false);
const [status, setStatus] = useState<SubmitStatus>("editing");
const [errorMessage, setErrorMessage] = useState("");
const totalQuantity = orders.reduce((sum, item) => sum + item.quantity, 0);
const totalPrice = orders.reduce((sum, item) => sum + item.quantity * item.price, 0);
const isLocked = status === "submitting" || status === "success";
const canSubmit = !isLocked && customerName.trim() !== "" && orders.length > 0;
function handleAddOrder(newItem: OrderItem) {
setOrders((prevOrders) => [...prevOrders, newItem]);
}
function handleDeleteOrder(id: string) {
setOrders((prevOrders) => prevOrders.filter((item) => item.id !== id));
}
function handleChangeQuantity(id: string, amount: number) {
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([]);
}
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));
setErrorMessage("");
setStatus("submitting");
try {
await simulateSubmit(shouldFail);
setStatus("success");
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : "發生未知錯誤,請再試一次。"
);
setStatus("error");
}
}
function handleBackToEdit() {
setErrorMessage("");
setStatus("editing");
}
return (
<main>
<header>
<p>DAY 15</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" 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="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}>
{status === "submitting"
? "模擬送出中…"
: status === "error" ? "重新模擬送出" : "模擬送出訂單"}
</button>
</section>
</fieldset>
<div role="status" aria-live="polite">
{status === "submitting" ? <p>正在模擬送出,請稍候…</p> : null}
{status === "success" ? (
<p>
模擬成功!{customerName.trim()} 的訂單共 {orders.length} 筆、
{totalQuantity} 杯,合計 {totalPrice} 元。
</p>
) : null}
</div>
{status === "error" ? <p role="alert">{errorMessage}</p> : null}
{status === "success" ? (
<button type="button" onClick={handleBackToEdit}>返回編輯</button>
) : null}
</div>
</div>
</form>
</main>
);
}
前面的 JSX 已用 className="order-layout" 包住兩個 div,目的是為了左欄放紅茶與綠茶,右欄放訂單清單與取餐資料,接著在 app/app.css 最後加上以下樣式,原本的樣式保留:
.order-layout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
align-items: start;
}
.order-layout > div {
min-width: 0;
}
@media (max-width: 800px) {
.order-layout {
grid-template-columns: 1fr;
}
}
grid-template-columns 把空間分成兩個等寬欄位,gap 留出欄間距;螢幕寬度不超過 800px 時,改為上下排列。左右兩欄各自使用一個 fieldset,並共用 disabled={isLocked},因此送出中會一起停用。
簡單地說,就是畫面不會太長~
接著是程式的內容,重點說明一下:
status 控制送出流程與畫面一開始是 editing,送出時改成 submitting,顯示等待訊息,並透過 isLocked 停用兩欄的操作。等待成功後改成 success,顯示訂單摘要;失敗則改成 error,顯示原因並恢復編輯,姓名、備註與清單都保留。
成功後按返回編輯,會回到 editing,失敗後再次送出,則先清除舊錯誤,再進入 submitting。畫面的訊息和可操作項目,都跟著這個流程狀態改變。從送出中到成功都保持鎖定,也讓成功摘要能對應送出時的姓名與清單。
canSubmit 統一送出條件首先找到 canSubmit,它表示「目前是否允許送出」,要能夠送出訂單,必須同時符合三個條件:
會有兩處使用到:
disabled={!canSubmit} 決定能不能操作。handleSubmit 開頭的 if (!canSubmit) return 在事件執行時決定是否繼續組資料、切換狀態或啟動模擬工作。按下送出時,submittedOrder 會組好姓名、整筆備註與已加入的項目,並用 JSON.stringify 記錄在 Console,其中的 items: orders 是沿用觸發當時的陣列,並不會去修改。
成功畫面會直接使用目前的姓名與清單,因為從送出中到成功,編輯都保持鎖定,失敗時則保留原本內容,讓使用者可以修正後重試。
再次送出先清除舊錯誤,成功訊息與等待訊息透過 role="status" 呈現,錯誤則使用 role="alert",讓這些變化不只是顏色上的差異。
畫面應該會是長這樣:

主要測試下面三個操作:

這裡可以觀察在編輯的時候底下的摘要文字是否也跟著變動,也就是 customerName 是有更新的,另外當清單是空的時候,送出按鈕會停用。

圖片是成功的結果,操作時還要觀察底下有沒有出現等待的狀態,以及操作控制項會停用。

失敗訊息出現後,取消勾選後再重試,就會顯示成功訊息了。
在 handleSubmit 的 try 裡:
// 錯誤示範
simulateSubmit(shouldFail);
setStatus("success");
模擬函式雖然已經啟動計時器,但呼叫端沒有等待 Promise,也就是沒有等待中間的工作完成,馬上就執行下一行,因為前面設定 submitting,緊接著又設定 success,在畫面上看起來就是立刻成功。
重新載入,改成勾選模擬失敗後送出,畫面仍會先顯示成功,但等待結束後 Console 會出現 未處理的 Promise 拒絕。

| 時機與位置 | 漏寫 await 的結果 |
|---|---|
| 按下送出後的畫面 | 很快顯示成功,沒有正確等待 |
| 模擬失敗時的畫面 | 仍然顯示成功 |
| 模擬失敗時的 Console | 出現未處理的 Promise 拒絕 |
| 原本的 catch | 沒接住這次未等待的非同步失敗 |
try...catch 不會替沒有等待、也沒有另外串接處理的 Promise 自動接手後來的失敗。
測試後記得補回 await,再檢查成功與失敗的狀態。
這章雖然程式比較長,但其實重點就是對 state 什麼時候更新,以及利用 state 來處理狀態與操作控制項的開關,另外的小項目就是 try...catch 和 await/async 要注意使用方式。
今天的姓名、備註、流程狀態、總杯數與送出條件,有些需要記住,有些可以算出來,明天再接著整理哪些資料需要放進 state 吧。![]()