iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
自我挑戰組

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

Day 15 : 簡潔有力,用 state 呈現表單輸入狀態

  • 分享至 

  • xImage
  •  

俗擱有力,簡單就是王道

今天目標

  1. 列出畫面狀態
  2. 用 state 控制輸入內容
  3. 用表單事件處理送出
  4. 等待結果,再切換成功或失敗
  5. 依狀態決定畫面與可操作項目
  6. 錯誤修正練習

今天要沿用訂單,加上取餐姓名和訂單備註,並且讓畫面能顯示訂單的狀態,這樣客人就可以看到自己訂單好了沒~

列出畫面狀態

可以先來想畫面的內容會對應什麼狀態:

畫面 是否能編輯 送出按鈕 狀態顯示
還沒填好 可以 停用 提醒填姓名、加入項目
已經填好 可以 可送出 姓名、備註與訂單摘要
等待結果 不可以 停用 模擬送出中
成功 先保持鎖定 停用 成功摘要、返回編輯按鈕
失敗 可以 資料有效時可重試 上一次失敗的原因

上面有五種畫面情況,其中「還沒填好」與「已經填好」都還在編輯,只需要檢查姓名和清單,就知道送出按鈕能不能用,那麼可以先把這兩個歸類在編輯中,這樣狀態會有四種:

// 型別放在元件外面。
type SubmitStatus = "editing" | "submitting" | "success" | "error";

// state 放在 DrinkOrder 的最上層。
const [status, setStatus] = useState<SubmitStatus>("editing");

這裡要限定 status 同一時間就只會是其中一個階段,否則若分別用 isSubmitting 和 isSuccess,一不小心兩個都設成 true,畫面就會同時說「請稍候」和「成功了」。

React:Reacting to Input with State

現在把變化原因接上去:

目前狀態 事件 下一個狀態
editing 或 error 使用者送出有效資料 submitting
submitting 模擬工作完成 success
submitting 模擬工作失敗 error
success 使用者按返回編輯 editing

用 state 控制輸入內容

前幾天的練習都有一個備註輸入框,只有 <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,讓姓名欄位有明確的標籤,也能透過點選標籤找到輸入位置。

React:Controlling an input with a state variable

整筆訂單備註也採用相同方式,只是換成可以輸入多行的 <textarea>:

// state 與 JSX 分別放在對應位置。
const [note, setNote] = useState("");

<textarea
  value={note}
  onChange={(event) => setNote(event.target.value)}
/>

React 的 <textarea> 同樣用 value 和 onChange 控制。

這份 note 是整筆訂單的備註,與紅茶、綠茶各自原本的練習備註分開,新加的整筆備註會在後面被放進模擬送出的資料中。

React:Controlling a text area with a state variable

為了讓成功和失敗我們都能測試,加一個「模擬這次送出失敗」的核取方塊:

// 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(),阻止瀏覽器原本的表單送出導覽,讓目前的頁面繼續處理這次互動。

MDN:submit 事件、MDN: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 秒的計時設定,讓我們有時間觀察等待畫面。

MDN:Promise

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。

MDN:await

因為 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 外面,才不會成功後連恢復操作的按鈕也一起鎖住。

MDN:fieldset 的 disabled

執行觀察

現在把表單接回昨天的點餐畫面,一些昨天已建立的內容我們沿用,其他修改參考下面的程式:

一、替換 React 匯入

把原本的 import { useState } from "react"; 換成:

import { useState, type SubmitEvent } from "react";

SubmitEvent<HTMLFormElement> 是下面表單送出事件的 React 型別,HTMLFormElement 指定處理事件的元素是表單。這裡的 SubmitEvent 要從 "react" 匯入,type 表示只匯入型別。

二、在 OrderItem 型別之後新增

下面兩個定義放在所有元件外面,只新增一次:

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

三、替換 DrinkOrder

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,它表示「目前是否允許送出」,要能夠送出訂單,必須同時符合三個條件:

  • 表單沒有被鎖定。
  • 姓名去掉前後空白後仍有內容。
  • 訂單清單至少有一筆。

會有兩處使用到:

  1. 送出訂單按鈕的 disabled={!canSubmit} 決定能不能操作。
  2. handleSubmit 開頭的 if (!canSubmit) return 在事件執行時決定是否繼續組資料、切換狀態或啟動模擬工作。

按下送出時,submittedOrder 會組好姓名、整筆備註與已加入的項目,並用 JSON.stringify 記錄在 Console,其中的 items: orders 是沿用觸發當時的陣列,並不會去修改。

成功畫面會直接使用目前的姓名與清單,因為從送出中到成功,編輯都保持鎖定,失敗時則保留原本內容,讓使用者可以修正後重試。

再次送出先清除舊錯誤,成功訊息與等待訊息透過 role="status" 呈現,錯誤則使用 role="alert",讓這些變化不只是顏色上的差異。

畫面應該會是長這樣:

https://ithelp.ithome.com.tw/upload/images/20260929/20184332yGzI5hV9WC.png

測試執行

主要測試下面三個操作:

  1. 姓名填「小明」,先不加入飲料

https://ithelp.ithome.com.tw/upload/images/20260929/20184332riabdmcfCk.png

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

  1. 加入 2 杯紅茶,整筆備註填「晚點取」,按模擬送出

https://ithelp.ithome.com.tw/upload/images/20260929/20184332L9pu2PdiRz.png

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

  1. 按返回編輯,勾選模擬失敗,再送出

https://ithelp.ithome.com.tw/upload/images/20260929/20184332IA3Un32GHV.png

失敗訊息出現後,取消勾選後再重試,就會顯示成功訊息了。

錯誤修正練習

少了 await 就立刻成功?

在 handleSubmit 的 try 裡:

// 錯誤示範
simulateSubmit(shouldFail);
setStatus("success");

模擬函式雖然已經啟動計時器,但呼叫端沒有等待 Promise,也就是沒有等待中間的工作完成,馬上就執行下一行,因為前面設定 submitting,緊接著又設定 success,在畫面上看起來就是立刻成功。

重新載入,改成勾選模擬失敗後送出,畫面仍會先顯示成功,但等待結束後 Console 會出現 未處理的 Promise 拒絕。

https://ithelp.ithome.com.tw/upload/images/20260929/20184332KXHjreRKQt.png

時機與位置 漏寫 await 的結果
按下送出後的畫面 很快顯示成功,沒有正確等待
模擬失敗時的畫面 仍然顯示成功
模擬失敗時的 Console 出現未處理的 Promise 拒絕
原本的 catch 沒接住這次未等待的非同步失敗

try...catch 不會替沒有等待、也沒有另外串接處理的 Promise 自動接手後來的失敗。

測試後記得補回 await,再檢查成功與失敗的狀態。


這章雖然程式比較長,但其實重點就是對 state 什麼時候更新,以及利用 state 來處理狀態與操作控制項的開關,另外的小項目就是 try...catch 和 await/async 要注意使用方式。

今天的姓名、備註、流程狀態、總杯數與送出條件,有些需要記住,有些可以算出來,明天再接著整理哪些資料需要放進 state 吧。/images/emoticon/emoticon56.gif

參考資料


上一篇
Day 14 : 不慌不忙,更新 state 裡的陣列
下一篇
Day 16 : 去蕪存菁,整理 state 的結構
系列文
React 入門到實作與除錯|30 天哩ㄟ刻 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言