學習完聲明式 UI 的思考及操作,接著要更深入來思考構築 state 的原則,官方文件提供了五個原則給使用者參考,讓我們一一來看。
是不是覺得似曾相似?這跟昨天我們學習過的內容核心概念相同的,其實一句話也就是「保持簡潔,避免重複、矛盾」。
當同時更新兩個或多個狀態變數,考慮將它們合併成一個單一的狀態變數。譬如座標經常使用 x, y
假如我們的目的是要更新滑鼠的點擊位置,那麼 x、y 更新經常一起使用的情況就會發生,這種時候比起這樣子去設定:
const [x, setX] = useState(0);
const [y, setY] = useState(0);
將兩者一起設定也是一種方法:
const [position, setPosition] = useState({ x: 0, y: 0 });
當狀態以一種方式結構化,以致於多個狀態片段可能相互矛盾並且不一致時,容易出錯,應盡量避免這種情況。譬如以下是一個回饋表單的程式碼
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) 發送完成」來看可以正常運作,但isSending
和 isSent
並沒有彼此約束的關係。有可能產生同時為「true」的情況,考量到 isSending
和 isSent
並不會同時為真,可以將二者以一個狀態變數來管理,譬如將二者透過 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
控制狀態來處理setIsSending
和 setIsSent
的切換,這樣可以確保 isSending
和 isSent
不會在同一時間都為 true
。而isSending
和isSent
仍然可以維持可讀性:
const isSending = status === "sending";
const isSent = status === "sent";
我們畫圖來比較看看。在這邊status
就像一個開關管理著isSending
和isSent
。
元件的屬性或現有的狀態變數在渲染過程中計算出某些訊息,則不應將該訊息放入該元件的狀態中。譬如這個例子,「全名」是可以透過「名字」和「姓」來處理的。所以「全名」這個是冗贅的。
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;
當相同的數據在多個狀態變數之間重複,或者在嵌套的對象中重複時,很難保持它們同步。在可以的情況下,減少重複。
看看底下這則程式碼:
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 會同時出現在items
和 selectedItem
當中, 二者都包含了 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
動態計算的。
深度巢狀的問題之前也有提過,這樣的階層狀態不方便更新,應該盡可能將它們「攤平」。
譬如類似這樣子的資料,若為巢狀的話會是這樣子的結構
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」按鈕時,它首先找到被完成的項目的父項目,然後創建一個新版本的父項目,該版本不再包含被完成的子項目。這樣子就完成移除的邏輯了。以上是今天的學習。