iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
自我挑戰組

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

Day 16 : 去蕪存菁,整理 state 的結構

  • 分享至 

  • xImage
  •  

很亂呀,很亂呀

今天目標

  1. 需要記住與可以算出的資料
  2. 把相關的狀態整理在一起
  3. ID 記住正在檢視的項目
  4. 檢查資料層級與複製的目的
  5. 測試清單、摘要與送出流程

需要記住與可以算出的資料

把目前已經有的值列出來:

資料 處理方式
訂單清單、姓名、備註 用 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。

React:Updating state based on props or 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 卻漏帶訊息,型別檢查也會提醒,但它不會幫我們安排送出順序或保證訊息不是空字串,還是要操作測試一下。/images/emoticon/emoticon13.gif

整理結構是為了讓資料關係清楚,但不需要把所有 state 塞進一個物件。
TypeScript:Discriminated unions

ID 記住正在檢視的項目

現在如果要新增詳細檢視,通常會這樣寫:

// 錯誤的示意片段
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));
}

如果刪除的是目前檢視的訂單,程式會清除選取,讓檢視區改顯示「尚未選取項目」,避免畫面繼續顯示已刪除的訂單內容。如果刪除的是其他訂單,檢視區則繼續顯示原本選取的訂單。

清空清單會移除所有訂單,因此也要清除選取,讓檢視區顯示「尚未選取項目」。

檢查資料層級與複製的目的

資料整理可以從五個方向檢查:

  • 一起變化的資料是否適合分組。
  • 狀態會不會矛盾。
  • 有沒有多存算得出來的值。
  • 同一份內容是否重複保存。
  • 層級是否深到難以更新。

React:Choosing the State Structure

例如目前的 orders -> item -> spec 很單純,維持相同的結構就可以了:

{ ...item, spec: { ...item.spec, sweetness: "無糖" } }

但之後如果資料變成門市、日期、桌號、訂單一路包下去,修改一個杯數都要往外複製好多層,才需要再考慮用 ID 拆開關係。

對於現階段的專案,還有一個差別要注意:「待加入的杯數」與「已加入的訂單」,分別是兩個不同階段的內容,像是把兩杯紅茶加入清單後,待加入杯數會歸零,而訂單並不會歸零。

另外檢視區要求隨清單更新,所以讀同一份訂單,如果以後想做送出歷史或可取消的編輯草稿,才需要確認保存時間點與套用規則,再決定是否保留另一份資料。

執行觀察與測試

替換送出狀態型別

刪除原本的 type SubmitStatus = ...,在元件外改為:

type SubmissionState =
  | { status: "editing" }
  | { status: "submitting" }
  | { status: "success" }
  | { status: "error"; errorMessage: string };

完整替換 DrinkOrder

完整替換 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>
  );
}

下面說明幾個重點:

  • 外層元件記住清單、姓名、備註、模擬失敗開關、送出結果與選取 ID。總計、送出條件、選取內容都在渲染時取得。
  • 刪除目前檢視的訂單時,程式會清除選取,檢視區會顯示「尚未選取項目」,避免顯示已刪除的內容。刪除其他訂單時,檢視區繼續顯示原本選取的訂單。
  • 不需要每次加杯或改甜度時再同步一份檢視物件,只要修改清單,下一次 find() 就會找到目前的內容。
  • 檢視按鈕在右欄 fieldset 裡,等待中與成功後停用,返回編輯按鈕一樣在外面。
  • submittedOrder 是當次事件的區域資料,成功畫面使用目前清單顯示摘要,因此送出中與成功後都停用編輯,確保摘要與送出內容一致。

測試執行

現在操作三個動作:

  1. 加入兩杯紅茶並選取,再按清單的「此筆加一杯」
    https://ithelp.ithome.com.tw/upload/images/20260930/20184332s3MeCFGgcY.png
    這裡是確認清單與檢視區使用同一份最新資料,避免內容不同步。

  2. 刪除目前選取的紅茶
    https://ithelp.ithome.com.tw/upload/images/20260930/20184332YqvbELLlYw.png

除了事件觸發,也同時確認檢視區沒有看到已刪除的訂單。

  1. 重新加入訂單並填好姓名,勾選模擬失敗後送出,再取消勾選重試
    https://ithelp.ithome.com.tw/upload/images/20260930/20184332YzQTeLcu0j.png

這一步主要是確認送出狀態可以正確切換,保留訂單資料並清除舊錯誤。

概念整理

  • 能算出的資料就直接計算:總杯數、總額都從訂單清單取得,避免多存一份後忘記同步更新。
  • 有關聯的流程資料一起管理:將送出狀態與失敗原因放在一起,重試時用新的等待狀態取代舊錯誤。
  • 用 ID 記住選取項目:檢視內容從目前清單查找,修改訂單後就能顯示最新資料,避免重複保存同一筆訂單。
  • 資料結構以容易更新為原則:避免過深的巢狀結構,也不用把簡單的飲料規格過度拆開。

明天接著把畫面拆成合作的元件,來看看兩個元件要用同一份資料時,state 應該放在哪裡,以及資料和事件要怎麼傳吧~

參考資料


上一篇
Day 15 : 簡潔有力,用 state 呈現表單輸入狀態
下一篇
Day 17 : 有福同享,讓元件共享 state
系列文
React 入門到實作與除錯|30 天哩ㄟ刻 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言