日常行為:ㄟ,我忘記要講什麼了?
我們已經知道需要共享的 state 可以放在共同的外層元件。今天接著做規格編輯區,讓使用者先改草稿,按套用後才更新訂單。首先就是編輯與暫存,切換編輯對象時,會需要知道每個元件的 state 保存與重設清空,接著來看看吧。
畫面上的紅茶與綠茶都使用 DrinkCounter 各自記住杯數。這裡我們可以觀察到元件函式相同, 但 state 沒有共用,這是因為它們在渲染樹中的身分是不同的元件實例(instance),分別保存狀態。
| 內容 | 與 state 的關係 |
|---|---|
| 元件在外層元件底下的位置 | 沒有指定 key 時,位置用來區分同一層的元件 |
| 元件類型 | 同一位置從 OrderEditor 換成 <p>,原編輯元件會被移除 |
key |
在同一個外層元件底下,指定元件的身分;改變它會重建該元件與底下的子樹 |
重新渲染時,如果同一位置仍是相同類型、相同 key 的元件,state 通常會保留。收到新的 props 可能會觸發新的畫面計算,但並不會因此重設元件的局部 state。元件被移除、類型改變,或 key 改變,就可能失去原本的局部 state。
上面說的位置是指渲染樹中的位置,跟程式第幾行沒關係
React:Preserving and Resetting State
昨天 OrderPreview 要隨清單顯示最新規格,透過使用 props 就可以做到。
今天新增草稿功能允許和已保存規格不同,例如:原本半糖少冰,可以先在草稿改無糖去冰,確認後才套用。因此我們刻意在 OrderEditor 保存局部 state。
// 示意片段,OrderEditor 內的重點
const [draftSpec, setDraftSpec] = useState<DrinkSpec>(() => ({ ...order.spec }));
畫面需要同時顯示「已保存規格」與「草稿規格」,修改草稿時只呼叫局部的 setter,按套用後才通知外層元件:
{/*示意片段*/}
<button type="button" onClick={() => onApply(order.id, { ...draftSpec })}>
套用規格
</button>
外層元件更新指定訂單,讓清單與檢視一起取得最新資料:
// 示意片段
function handleApplySpec(id: string, spec: DrinkSpec) {
if (isLocked) return;
setOrders((prevOrders) =>
prevOrders.map((item) =>
item.id === id ? { ...item, spec: { ...spec } } : item
)
);
}
杯數、單價與 ID 維持原值,只替換規格,編輯區放在 disabled={isLocked} 的 fieldset 裡,新按鈕都用 type="button"。
在檢視元件後面、取餐資料前面,加入沒有訂單 key 的版本:
{selectedOrder ? (
<OrderEditor order={selectedOrder} onApply={handleApplySpec} />
) : null}
準備兩筆不同規格的訂單:紅茶 A 為半糖少冰,綠茶 B 為全糖正常冰。
當我們選 A,把草稿改無糖去冰且不套用時,直接切到 B。
會觀察到標題與已保存規格已變成 B 的內容,草稿卻還是 A 留下的無糖去冰,這是因為同一位置仍是同一類型的 OrderEditor,React 保留了 draftSpec。
把剛才的條件渲染改成:
{selectedOrder ? (
<OrderEditor
key={selectedOrder.id}
order={selectedOrder}
onApply={handleApplySpec}
/>
) : null}
切到 B 時,key 改變,React 移除 A 的編輯元件。再建立 B 的編輯元件,新的草稿就從 B 已保存的規格開始,同名的紅茶也用各筆 ID 區分。
key 不限於清單,也可以用在單一表單元件。它讓同一個外層元件底下區分身分,不會當成一般 prop 傳入,而編輯元件還是可以透過 order.id 取得訂單 ID。
這次需求的規則是切換會放棄未套用草稿,且key 不會替 A 快取草稿,再切回 A 會建立新的編輯元件,並讀取 A 的已保存規格。
先在 DrinkOrder 的其他 Hook 旁新增:
const [showEditor, setShowEditor] = useState(true);
測試把「用 key 重設草稿」中的整段條件渲染換成以下程式,放在右欄的 fieldset 裡:
<section aria-label="編輯區顯示測試">
<label>
<input
type="checkbox"
checked={showEditor}
onChange={(event) => setShowEditor(event.target.checked)}
/>
顯示規格編輯區
</label>
{selectedOrder ? (
showEditor ? (
<OrderEditor
key={selectedOrder.id}
order={selectedOrder}
onApply={handleApplySpec}
/>
) : (
<p>編輯區已移除,再顯示時會重新建立草稿。</p>
)
) : null}
</section>
試著這樣操作:存檔、重新載入、選取 A,改草稿但不套用,再取消與重新勾選。

會發現草稿回到已保存規格,相同位置換成 <p> 時,OrderEditor 被移除,局部 state 也消失。換回來之後,雖然仍然寫相同訂單 key,也不會找回被移除的草稿。
另一種情況是:切換時,連包住 OrderEditor 的標籤也從 <section> 換成 <div>。React 會重建這段子樹,裡面的草稿也會重設。所以只看編輯元件自己的名稱和 key 不夠,還要看上方結構是否保留。
disabled 只會停用控制項,不會移除編輯元件。例如模擬送出時,編輯區雖然不能操作,草稿仍會保留。使用 hidden 隱藏時,只要元件仍在渲染樹中,草稿也會保留。
若改成固定渲染元件,只用外層的 hidden 屬性切換可見性,元件仍在樹裡,草稿則會保留。
重點是:看不見、停用與從樹中移除,是三種不同情況。
準備[紅茶 A] 兩杯、半糖少冰,[綠茶 B] 一杯、全糖正常冰,合計 85 元

這裡觀察同一元件保留局部 state,草稿在按下檢視按鈕時不會被移除。


切去 B,再切回 A,會因為 key 改變後重設 state,這時草稿會被還原。

按下套用草稿後,會透過回呼套用到已保存規格,這個時候再切換的話,已保存規格不會被影響。
import { useState, type SubmitEvent } from "react";
import { Link } from "react-router";
type SubmissionState =
| { status: "editing" }
| { status: "submitting" }
| { status: "success" }
| { status: "error"; errorMessage: string };
function simulateSubmit(shouldFail: boolean): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) {
reject(new Error("模擬送出失敗,請取消勾選失敗選項後重試。"));
} else {
resolve();
}
}, 1500);
});
}
type DrinkSpec = {
sweetness: "全糖" | "半糖" | "無糖";
ice: "正常冰" | "少冰" | "去冰";
};
type OrderItem = {
id: string;
name: string;
price: number;
quantity: number;
spec: DrinkSpec;
};
type DrinkNameProps = {
name: string;
};
function DrinkName({ name }: DrinkNameProps) {
return <h2>{name}</h2>;
}
type DrinkCounterProps = {
name: string;
price: number;
onAddOrder: (item: OrderItem) => void;
};
function DrinkCounter({ name, price, onAddOrder }: DrinkCounterProps) {
const [quantity, setQuantity] = useState(0);
const [spec, setSpec] = useState<DrinkSpec>({
sweetness: "全糖",
ice: "正常冰",
});
function handleAdd() {
const nextQuantity = quantity + 1;
setQuantity(nextQuantity);
console.log("[事件] 這次讀到的杯數", name, quantity);
console.log("[事件] 準備更新的杯數與小計", name, nextQuantity, nextQuantity * price);
}
function handleAddThree() {
setQuantity((prevQuantity) => prevQuantity + 3);
}
function handleRemove() {
setQuantity((prevQuantity) => Math.max(prevQuantity - 1, 0));
}
function handleClear() {
setQuantity(0);
}
function handleSweetness(sweetness: DrinkSpec["sweetness"]) {
console.log("[事件] 指定甜度", name, sweetness);
setSpec((prevSpec) => ({ ...prevSpec, sweetness }));
}
function handleIce(ice: DrinkSpec["ice"]) {
console.log("[事件] 指定冰塊", name, ice);
setSpec((prevSpec) => ({ ...prevSpec, ice }));
}
function handleResetSpec() {
setSpec({ sweetness: "全糖", ice: "正常冰" });
}
function handlePreset() {
setSpec((prevSpec) => ({ ...prevSpec, sweetness: "無糖", ice: "去冰" }));
}
function handleAddToOrder() {
if (quantity === 0) return;
const newItem: OrderItem = {
id: crypto.randomUUID(),
name,
price,
quantity,
spec: { ...spec },
};
onAddOrder(newItem);
setQuantity(0);
}
function handleReportLater() {
console.log("[事件] 預約回報", name, quantity, spec.sweetness, spec.ice);
setTimeout(() => {
console.log("[延遲] 按下回報時的資料", name, quantity, spec.sweetness, spec.ice);
}, 3000);
}
return (
<div data-drink={name}>
<DrinkName name={name} />
<p>待加入 {quantity} 杯</p>
<p>小計:{quantity * price} 元</p>
<p>規格:{spec.sweetness}/{spec.ice}</p>
<div>
<button type="button" onClick={handleAdd}>加一杯</button>
<button type="button" onClick={handleAddThree}>一次加三杯</button>
<button type="button" onClick={handleRemove} disabled={quantity === 0}>
減一杯
</button>
<button type="button" onClick={handleClear}>清空杯數</button>
</div>
<fieldset>
<legend>{name}甜度</legend>
<button type="button" aria-pressed={spec.sweetness === "全糖"}
onClick={() => handleSweetness("全糖")}>
全糖
</button>
<button type="button" aria-pressed={spec.sweetness === "半糖"}
onClick={() => handleSweetness("半糖")}>
半糖
</button>
<button type="button" aria-pressed={spec.sweetness === "無糖"}
onClick={() => handleSweetness("無糖")}>
無糖
</button>
</fieldset>
<fieldset>
<legend>{name}冰塊</legend>
<button type="button" aria-pressed={spec.ice === "正常冰"}
onClick={() => handleIce("正常冰")}>
正常冰
</button>
<button type="button" aria-pressed={spec.ice === "少冰"}
onClick={() => handleIce("少冰")}>
少冰
</button>
<button type="button" aria-pressed={spec.ice === "去冰"}
onClick={() => handleIce("去冰")}>
去冰
</button>
</fieldset>
<div>
<button type="button" onClick={handleResetSpec}>恢復預設規格</button>
<button type="button" onClick={handlePreset}>一鍵無糖去冰</button>
<button type="button" onClick={handleReportLater}>3 秒後回報杯數與規格</button>
</div>
<label>
{name}練習備註(未加入清單):
<input type="text" placeholder="試著輸入晚點取" />
</label>
<div>
<button type="button" onClick={handleAddToOrder} disabled={quantity === 0}>
加入訂單清單
</button>
</div>
</div>
);
}
type DrinkId = "black-tea" | "green-tea";
type DrinkDetailsProps = {
name: string;
description: string;
isExpanded: boolean;
onToggle: () => void;
};
function DrinkDetails({ name, description, isExpanded, onToggle }: DrinkDetailsProps) {
return (
<div>
<button type="button" aria-expanded={isExpanded} onClick={onToggle}>
{isExpanded ? "收合" : "展開"}「{name}」介紹
</button>
{isExpanded ? <p>{description}</p> : null}
</div>
);
}
type OrderListProps = {
orders: OrderItem[];
selectedOrderId: string | null;
totalQuantity: number;
totalPrice: number;
onSelect: (id: string) => void;
onChangeQuantity: (id: string, amount: number) => void;
onSugarFree: (id: string) => void;
onDelete: (id: string) => void;
onClear: () => void;
onReportLater: () => void;
};
function OrderList({
orders, selectedOrderId, totalQuantity, totalPrice,
onSelect, onChangeQuantity, onSugarFree, onDelete, onClear, onReportLater,
}: OrderListProps) {
return (
<section aria-labelledby="order-heading">
<h2 id="order-heading">訂單清單</h2>
<p>共 {orders.length} 筆、{totalQuantity} 杯,合計 {totalPrice} 元</p>
<button type="button" onClick={onClear} disabled={orders.length === 0}>
清空訂單清單
</button>
<button type="button" onClick={onReportLater}>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={() => onSelect(item.id)}>
檢視此筆
</button>
<button type="button" onClick={() => onChangeQuantity(item.id, 1)}>
此筆加一杯
</button>
<button type="button" onClick={() => onChangeQuantity(item.id, -1)}
disabled={item.quantity === 1}>
此筆減一杯
</button>
<button type="button" onClick={() => onSugarFree(item.id)}>此筆改無糖</button>
<button type="button" onClick={() => onDelete(item.id)}>刪除此筆</button>
</li>
))}
</ul>
)}
</section>
);
}
type OrderPreviewProps = {
order: OrderItem | undefined;
onClearSelection: () => void;
onPreset: (id: string) => void;
};
function OrderPreview({ order, onClearSelection, onPreset }: OrderPreviewProps) {
return (
<section aria-labelledby="selected-heading">
<h2 id="selected-heading">目前檢視的項目</h2>
{order ? (
<div>
<p>{order.name},{order.quantity} 杯</p>
<p>規格:{order.spec.sweetness}/{order.spec.ice}</p>
<p>小計:{order.quantity * order.price} 元</p>
<button type="button" onClick={() => onPreset(order.id)}>
此筆一鍵無糖去冰
</button>
<button type="button" onClick={onClearSelection}>取消檢視</button>
</div>
) : (
<p>尚未選取項目,請按清單裡的「檢視此筆」。</p>
)}
</section>
);
}
type OrderEditorProps = {
order: OrderItem;
onApply: (id: string, spec: DrinkSpec) => void;
};
function OrderEditor({ order, onApply }: OrderEditorProps) {
const [draftSpec, setDraftSpec] = useState<DrinkSpec>(() => ({ ...order.spec }));
return (
<section aria-labelledby="editor-heading">
<h2 id="editor-heading">編輯規格草稿:{order.name}</h2>
<p>已保存規格:{order.spec.sweetness}/{order.spec.ice}</p>
<p>草稿規格:{draftSpec.sweetness}/{draftSpec.ice}</p>
<p>切換或取消檢視會放棄未套用草稿;送出只使用已保存規格。</p>
<fieldset>
<legend>草稿甜度</legend>
{(["全糖", "半糖", "無糖"] as const).map((sweetness) => (
<button
key={sweetness}
type="button"
aria-pressed={draftSpec.sweetness === sweetness}
onClick={() => setDraftSpec((prevSpec) => ({ ...prevSpec, sweetness }))}
>
{sweetness}
</button>
))}
</fieldset>
<fieldset>
<legend>草稿冰塊</legend>
{(["正常冰", "少冰", "去冰"] as const).map((ice) => (
<button
key={ice}
type="button"
aria-pressed={draftSpec.ice === ice}
onClick={() => setDraftSpec((prevSpec) => ({ ...prevSpec, ice }))}
>
{ice}
</button>
))}
</fieldset>
<button type="button" onClick={() => onApply(order.id, { ...draftSpec })}>
套用規格
</button>
<button type="button" onClick={() => setDraftSpec({ ...order.spec })}>
恢復已保存規格
</button>
</section>
);
}
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 [expandedDrinkId, setExpandedDrinkId] = useState<DrinkId | 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 handleApplySpec(id: string, spec: DrinkSpec) {
if (isLocked) return;
setOrders((prevOrders) =>
prevOrders.map((item) =>
item.id === id ? { ...item, spec: { ...spec } } : item
)
);
}
function handleSelectOrder(id: string) {
setSelectedOrderId(id);
}
function handleClearSelection() {
setSelectedOrderId(null);
}
function handleToggleDetails(id: DrinkId) {
setExpandedDrinkId((prevId) => (prevId === id ? null : id));
}
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 handleOrderPreset(id: string) {
setOrders((prevOrders) =>
prevOrders.map((item) =>
item.id === id
? { ...item, spec: { ...item.spec, sweetness: "無糖", ice: "去冰" } }
: 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 18</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="茶香濃郁,口感醇厚。"
isExpanded={expandedDrinkId === "black-tea"}
onToggle={() => handleToggleDetails("black-tea")}
/>
</section>
<section>
<DrinkCounter name="綠茶" price={25} onAddOrder={handleAddOrder} />
<DrinkDetails
name="綠茶"
description="清爽茶香,適合喜歡清淡口味的人。"
isExpanded={expandedDrinkId === "green-tea"}
onToggle={() => handleToggleDetails("green-tea")}
/>
</section>
</fieldset>
</div>
<div>
<fieldset disabled={isLocked}>
<legend>編輯訂單與取餐資料</legend>
<OrderList
orders={orders}
selectedOrderId={selectedOrderId}
totalQuantity={totalQuantity}
totalPrice={totalPrice}
onSelect={handleSelectOrder}
onChangeQuantity={handleChangeQuantity}
onSugarFree={handleOrderSugarFree}
onDelete={handleDeleteOrder}
onClear={handleClearOrders}
onReportLater={handleReportOrdersLater}
/>
<OrderPreview
order={selectedOrder}
onClearSelection={handleClearSelection}
onPreset={handleOrderPreset}
/>
{selectedOrder ? (
<OrderEditor
key={selectedOrder.id}
order={selectedOrder}
onApply={handleApplySpec}
/>
) : null}
<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>
);
}