iT邦幫忙

2023 iThome 鐵人賽

DAY 19
0

學習完聲明式 UI 的思考及操作,接著要更深入來思考構築 state 的原則,官方文件提供了五個原則給使用者參考,讓我們一一來看。

  1. 將相關的狀態群組(Group related state)
  2. 避免狀態的矛盾(Avoid contradictions in state)
  3. 避免冗贅的狀態(Avoid redundant state)
  4. 避免狀態中的重複(Avoid duplication in state)
  5. 避免深度巢狀的狀態(Avoid deeply nested state)

是不是覺得似曾相似?這跟昨天我們學習過的內容核心概念相同的,其實一句話也就是「保持簡潔,避免重複、矛盾」。

1. 將相關的狀態群組

當同時更新兩個或多個狀態變數,考慮將它們合併成一個單一的狀態變數。譬如座標經常使用 x, y

假如我們的目的是要更新滑鼠的點擊位置,那麼 x、y 更新經常一起使用的情況就會發生,這種時候比起這樣子去設定:

const [x, setX] = useState(0);
const [y, setY] = useState(0);

將兩者一起設定也是一種方法:

const [position, setPosition] = useState({ x: 0, y: 0 });

2. 避免狀態的矛盾

當狀態以一種方式結構化,以致於多個狀態片段可能相互矛盾並且不一致時,容易出錯,應盡量避免這種情況。譬如以下是一個回饋表單的程式碼

import { useState } from "react";

export default function FeedbackForm() {
  const [text, setText] = useState("");
  const [isSending, setIsSending] = useState(false);
  const [isSent, setIsSent] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();
    setIsSending(true);
    await sendMessage(text);
    setIsSending(false);
    setIsSent(true);
  }

  if (isSent) {
    return <h1>Thanks for feedback!</h1>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <p>How was your stay at The Prancing Pony?</p>
      <textarea
        disabled={isSending}
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <br />
      <button disabled={isSending} type="submit">
        Send
      </button>
      {isSending && <p>Sending...</p>}
    </form>
  );
}

// Pretend to send a message.
function sendMessage(text) {
  return new Promise((resolve) => {
    setTimeout(resolve, 2000);
  });
}

雖然照目前的邏輯「click 點擊 → isSending(true)發送 → isSending(false) 發送結束 → isSent (true) 發送完成」來看可以正常運作,但isSendingisSent 並沒有彼此約束的關係。有可能產生同時為「true」的情況,考量到 isSendingisSent 並不會同時為真,可以將二者以一個狀態變數來管理,譬如將二者透過 status 的狀態變數來控制:

import { useState } from "react";

export default function FeedbackForm() {
  const [text, setText] = useState("");
  const [status, setStatus] = useState("typing");

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus("sending");
    await sendMessage(text);
    setStatus("sent");
  }

  const isSending = status === "sending";
  const isSent = status === "sent";

  if (isSent) {
    return <h1>Thanks for feedback!</h1>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <p>How was your stay at The Prancing Pony?</p>
      <textarea
        disabled={isSending}
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <br />
      <button disabled={isSending} type="submit">
        Send
      </button>
      {isSending && <p>Sending...</p>}
    </form>
  );
}

// Pretend to send a message.
function sendMessage(text) {
  return new Promise((resolve) => {
    setTimeout(resolve, 2000);
  });
}

使用 status 控制狀態來處理setIsSendingsetIsSent的切換,這樣可以確保 isSendingisSent 不會在同一時間都為 true。而isSendingisSent仍然可以維持可讀性:

const isSending = status === "sending";
const isSent = status === "sent";

我們畫圖來比較看看。在這邊status就像一個開關管理著isSendingisSent

3. 避免冗贅的狀態

元件的屬性或現有的狀態變數在渲染過程中計算出某些訊息,則不應將該訊息放入該元件的狀態中。譬如這個例子,「全名」是可以透過「名字」和「姓」來處理的。所以「全名」這個是冗贅的。

import { useState } from "react";

export default function Form() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");
  const [fullName, setFullName] = useState("");

  function handleFirstNameChange(e) {
    setFirstName(e.target.value);
    setFullName(e.target.value + " " + lastName);
  }

  function handleLastNameChange(e) {
    setLastName(e.target.value);
    setFullName(firstName + " " + e.target.value);
  }

  return (
    <>
      <h2>Let’s check you in</h2>
      <label>
        First name: <input value={firstName} onChange={handleFirstNameChange} />
      </label>
      <label>
        Last name: <input value={lastName} onChange={handleLastNameChange} />
      </label>
      <p>
        Your ticket will be issued to: <b>{fullName}</b>
      </p>
    </>
  );
}

我們只需要調整成這樣子,就能更加的簡潔:

const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");

const fullName = firstName + " " + lastName;

4. 避免狀態中的重複(Avoid duplication in state)

當相同的數據在多個狀態變數之間重複,或者在嵌套的對象中重複時,很難保持它們同步。在可以的情況下,減少重複。

看看底下這則程式碼:

import { useState } from "react";

const initialItems = [
  { title: "pretzels", id: 0 },
  { title: "crispy seaweed", id: 1 },
  { title: "granola bar", id: 2 },
];

export default function Menu() {
  const [items, setItems] = useState(initialItems);
  const [selectedItem, setSelectedItem] = useState(items[0]);

  function handleItemChange(id, e) {
    setItems(
      items.map((item) => {
        if (item.id === id) {
          return {
            ...item,
            title: e.target.value,
          };
        } else {
          return item;
        }
      })
    );
  }

  return (
    <>
      <h2>What's your travel snack?</h2>
      <ul>
        {items.map((item, index) => (
          <li key={item.id}>
            <input
              value={item.title}
              onChange={(e) => {
                handleItemChange(item.id, e);
              }}
            />{" "}
            <button
              onClick={() => {
                setSelectedItem(item);
              }}>
              Choose
            </button>
          </li>
        ))}
      </ul>
      <p>You picked {selectedItem.title}.</p>
    </>
  );
}

它渲染出來會長這樣子:

當我們點擊按鈕「Choose」時會顯依據所對應的內容顯示底下的句子,也就是 You picked _____. 若是我們先編輯好 input 欄位再點選「Choose」的按鈕,則內容可以正常運作,但是如果我們先點擊了按鈕,再重新編 input 框的內容,那麼底下的句子並不會隨著內容更新而一起更新。

會有這樣的結果是因為有「duplicate state」的存在,「duplicate state」指的是相同的資料被儲存在兩個不同的地方。假如,我們點擊了 crispy seaweed 的按鈕,crispy seaweed 會同時出現在itemsselectedItem當中, 二者都包含了 crispy seaweed,但這時我們如果修改了items,由於忘記寫 selectedItem的邏輯,所以出現了不同步的現象。

雖然直接補上的邏輯,也能夠解決問題,但比較好的方法還是將重複的部分移除。

我們可以透過其他方法來鎖定 selectedID:

import { useState } from "react";

const initialItems = [
  { title: "pretzels", id: 0 },
  { title: "crispy seaweed", id: 1 },
  { title: "granola bar", id: 2 },
];

export default function Menu() {
  const [items, setItems] = useState(initialItems);
  const [selectedId, setSelectedId] = useState(0);

  const selectedItem = items.find((item) => item.id === selectedId);

  function handleItemChange(id, e) {
    setItems(
      items.map((item) => {
        if (item.id === id) {
          return {
            ...item,
            title: e.target.value,
          };
        } else {
          return item;
        }
      })
    );
  }

  return (
    <>
      <h2>What's your travel snack?</h2>
      <ul>
        {items.map((item, index) => (
          <li key={item.id}>
            <input
              value={item.title}
              onChange={(e) => {
                handleItemChange(item.id, e);
              }}
            />{" "}
            <button
              onClick={() => {
                setSelectedId(item.id);
              }}>
              Choose
            </button>
          </li>
        ))}
      </ul>
      <p>You picked {selectedItem.title}.</p>
    </>
  );
}

selectedId,並將其初始化為 0,並透過 selectedId 來計算 selectedItem,以在 items 陣列中尋找具有相同 id 的項目的方法來取得所選擇的項目。而在 handleItemChange 函數中,修改項目的 title 時,不再需要同步更新 selectedItem,因為 selectedItem 是基於 selectedId 動態計算的。

5. 避免深度巢狀的狀態

深度巢狀的問題之前也有提過,這樣的階層狀態不方便更新,應該盡可能將它們「攤平」。

譬如類似這樣子的資料,若為巢狀的話會是這樣子的結構

export const initialTravelPlan = {
  id: 0,
  title: '(Root)',
  childPlaces: [{
    id: 1,
    title: 'Earth',
    childPlaces: [{
      id: 2,
      title: 'Africa',
      childPlaces: [{
        id: 3,
        title: 'Botswana',
        childPlaces: []
      },
// ...
, {
    id: 42,
    title: 'Moon',
    childPlaces: [{
      id: 43,
      title: 'Rheita',
      childPlaces: []
    },
//...

但若是將它攤平可以寫成這樣:

export const initialTravelPlan = {
  0: {
    id: 0,
    title: "(Root)",
    childIds: [1, 42, 46],
  },
  1: {
    id: 1,
    title: "Earth",
    childIds: [2, 10, 19, 26, 34],
  },
  2: {
    id: 2,
    title: "Africa",
    childIds: [3, 4, 5, 6, 7, 8, 9],
  },
  // ...
  10: {
    id: 10,
    title: "Americas",
    childIds: [11, 12, 13, 14, 15, 16, 17, 18],
  },
  // ...
};

畫圖來看看應該能更加明白:

也就是透過 childIds 來達到連結子資料。這樣平面的資料除了稱作flat,也可以稱作normalized

接下來試試將攤平的資料移除看看,這邊要做的是當用戶點擊「Complete」按鈕完成該項目時,代表已完成,所以要移除該項目。

這邊的重點是:

移除一個項目代表的意思是什麼?

import { useState } from "react";
import { initialTravelPlan } from "./places.js";

export default function TravelPlan() {
  const [plan, setPlan] = useState(initialTravelPlan);

  function handleComplete(parentId, childId) {
    const parent = plan[parentId];

    const nextParent = {
      ...parent,
      childIds: parent.childIds.filter((id) => id !== childId),
    };

    setPlan({
      ...plan,

      [parentId]: nextParent,
    });
  }

  const root = plan[0];
  const planetIds = root.childIds;
  return (
    <>
      <h2>Places to visit</h2>
      <ol>
        {planetIds.map((id) => (
          <PlaceTree
            key={id}
            id={id}
            parentId={0}
            placesById={plan}
            onComplete={handleComplete}
          />
        ))}
      </ol>
    </>
  );
}

function PlaceTree({ id, parentId, placesById, onComplete }) {
  const place = placesById[id];
  const childIds = place.childIds;
  return (
    <li>
      {place.title}
      <button
        onClick={() => {
          onComplete(parentId, id);
        }}>
        Complete
      </button>
      {childIds.length > 0 && (
        <ol>
          {childIds.map((childId) => (
            <PlaceTree
              key={childId}
              id={childId}
              parentId={id}
              placesById={placesById}
              onComplete={onComplete}
            />
          ))}
        </ol>
      )}
    </li>
  );
}

當用戶點擊「Complete」按鈕時,它首先找到被完成的項目的父項目,然後創建一個新版本的父項目,該版本不再包含被完成的子項目。這樣子就完成移除的邏輯了。以上是今天的學習。

參考資料

  • React 官方文件 - Choosing the State Structure

上一篇
Day 18 - 聲明式 UI 的思考及操作
下一篇
Day 20 - 在元件間共用 state
系列文
30 days of React 30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言