iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
自我挑戰組

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

Day 18 : 留還是不留,理解 state 的保留與重設

  • 分享至 

  • xImage
  •  

日常行為:ㄟ,我忘記要講什麼了?

今天目標

  1. 元件的 state 保留與重設
  2. 觀察 props 改變對 state 的影響
  3. 用 key 重設草稿
  4. 分辨停用、隱藏與移除
  5. 執行觀察

我們已經知道需要共享的 state 可以放在共同的外層元件。今天接著做規格編輯區,讓使用者先改草稿,按套用後才更新訂單。首先就是編輯與暫存,切換編輯對象時,會需要知道每個元件的 state 保存與重設清空,接著來看看吧。

元件的 state 保留與重設

同一個元件函式,可以有不同的 state

畫面上的紅茶與綠茶都使用 DrinkCounter 各自記住杯數。這裡我們可以觀察到元件函式相同, 但 state 沒有共用,這是因為它們在渲染樹中的身分是不同的元件實例(instance),分別保存狀態。

內容 與 state 的關係
元件在外層元件底下的位置 沒有指定 key 時,位置用來區分同一層的元件
元件類型 同一位置從 OrderEditor 換成 <p>,原編輯元件會被移除
key 在同一個外層元件底下,指定元件的身分;改變它會重建該元件與底下的子樹

重新渲染時,如果同一位置仍是相同類型、相同 key 的元件,state 通常會保留。收到新的 props 可能會觸發新的畫面計算,但並不會因此重設元件的局部 state。元件被移除、類型改變,或 key 改變,就可能失去原本的局部 state。

上面說的位置是指渲染樹中的位置,跟程式第幾行沒關係
React:Preserving and Resetting State

觀察 props 改變對 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 的切換

在檢視元件後面、取餐資料前面,加入沒有訂單 key 的版本:

{selectedOrder ? (
  <OrderEditor order={selectedOrder} onApply={handleApplySpec} />
) : null}

準備兩筆不同規格的訂單:紅茶 A 為半糖少冰,綠茶 B 為全糖正常冰。

當我們選 A,把草稿改無糖去冰且不套用時,直接切到 B。

會觀察到標題與已保存規格已變成 B 的內容,草稿卻還是 A 留下的無糖去冰,這是因為同一位置仍是同一類型的 OrderEditor,React 保留了 draftSpec。

用 key 重設草稿

把剛才的條件渲染改成:

{selectedOrder ? (
  <OrderEditor
    key={selectedOrder.id}
    order={selectedOrder}
    onApply={handleApplySpec}
  />
) : null}

切到 B 時,key 改變,React 移除 A 的編輯元件。再建立 B 的編輯元件,新的草稿就從 B 已保存的規格開始,同名的紅茶也用各筆 ID 區分。

React:Resetting a form with a key

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,改草稿但不套用,再取消與重新勾選。

https://ithelp.ithome.com.tw/upload/images/20261002/20184332feYF4lphhv.png

會發現草稿回到已保存規格,相同位置換成 <p> 時,OrderEditor 被移除,局部 state 也消失。換回來之後,雖然仍然寫相同訂單 key,也不會找回被移除的草稿。

改變上方結構也會重設草稿

另一種情況是:切換時,連包住 OrderEditor 的標籤也從 <section> 換成 <div>。React 會重建這段子樹,裡面的草稿也會重設。所以只看編輯元件自己的名稱和 key 不夠,還要看上方結構是否保留。

停用與隱藏仍保留 state

disabled 只會停用控制項,不會移除編輯元件。例如模擬送出時,編輯區雖然不能操作,草稿仍會保留。使用 hidden 隱藏時,只要元件仍在渲染樹中,草稿也會保留。

若改成固定渲染元件,只用外層的 hidden 屬性切換可見性,元件仍在樹裡,草稿則會保留。

重點是:看不見、停用與從樹中移除,是三種不同情況。

執行觀察

準備[紅茶 A] 兩杯、半糖少冰,[綠茶 B] 一杯、全糖正常冰,合計 85 元

選 A,草稿改無糖去冰,不套用,再按 A 的檢視按鈕:

https://ithelp.ithome.com.tw/upload/images/20261002/20184332cdjvrFy1Ie.png

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

直接切 B,再切回 A

https://ithelp.ithome.com.tw/upload/images/20261002/20184332DIJbpo5kJa.png
https://ithelp.ithome.com.tw/upload/images/20261002/20184332Jnk4MX9r2u.png

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

A 草稿再次改無糖去冰,再按套用

https://ithelp.ithome.com.tw/upload/images/20261002/20184332wVhjRLSGwc.png

按下套用草稿後,會透過回呼套用到已保存規格,這個時候再切換的話,已保存規格不會被影響。


參考資料

附錄(放不下的完整程式)

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>
  );
}

上一篇
Day 17 : 有福同享,讓元件共享 state
系列文
React 入門到實作與除錯|30 天哩ㄟ刻 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言