良好的狀態結構可以讓元件更易於修改和除錯,避免成為錯誤的根源。以下是在構建狀態時應考慮的一些技巧。
你將學到
- 何時使用單個與多個狀態變數
- 組織狀態時應避免的事項
- 如何修復狀態結構的常見問題
狀態結構的原則
當你編寫一個包含狀態的元件時,你必須選擇使用多少個狀態變數以及它們的資料形狀。雖然即使狀態結構不佳也能編寫正確的程式,但有一些原則可以指導你做出更好的選擇。
- 將相關狀態分組。 如果你總是同時更新兩個或多個狀態變數,請考慮將它們合併成單個狀態變數。
- 避免狀態矛盾。 當狀態的結構方式可能導致多個狀態片段相互矛盾和“不一致”時,就會留下出錯的空間。盡量避免這種情況。
- 避免冗餘狀態。 如果你可以在渲染過程中從元件的 props 或其現有狀態變數計算某些資訊,則不應將該資訊放入該元件的狀態中。
- 避免狀態重複。 當相同的資料在多個狀態變數之間或嵌套物件內重複時,很難保持它們同步。盡可能減少重複。
- 避免深度嵌套狀態。 深度階層狀態更新起來不是很方便。盡可能以扁平的方式構建狀態。
這些原則的目標是*在不引入錯誤的情況下輕鬆更新狀態*。從狀態中移除冗餘和重複資料有助於確保所有片段保持同步。這類似於資料庫工程師可能希望 “正規化”資料庫結構 以減少錯誤的可能性。套用愛因斯坦的話,“讓你的狀態盡可能簡單——但不要過於簡化。”
現在讓我們看看這些原則如何在實踐中應用。
將相關狀態分組
你可能有時不確定是使用單個還是多個狀態變數。
你應該這樣做嗎?
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 MovingDot() { const [position, setPosition] = useState({ x: 0, y: 0 }); return ( <div onPointerMove={e => { setPosition({ x: e.clientX, y: e.clientY }); }} style={{ position: 'relative', width: '100vw', height: '100vh', }}> <div style={{ position: 'absolute', backgroundColor: 'red', borderRadius: '50%', transform: `translate(${position.x}px, ${position.y}px)`, left: -10, top: -10, width: 20, height: 20, }} /> </div> ) }
將資料分組到物件或陣列中的另一種情況是,當你不知道你需要多少個狀態片段時。例如,當你有一個使用者可以新增自定義欄位的表單時,這很有幫助。
避免狀態矛盾
這是一個飯店意見回饋表單,其中包含 isSending
和 isSent
狀態變數
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); }); }
雖然這段程式碼可以運作,但它為「不可能」的狀態留下了空間。例如,如果您忘記同時呼叫 setIsSent
和 setIsSending
,您可能會遇到 isSending
和 isSent
同時為 true
的情況。您的元件越複雜,就越難理解發生了什麼事。
由於 isSending
和 isSent
永遠不應該同時為 true
,因此最好將它們替換為一個 status
狀態變數,該變數可以是以下 _三種_ 有效狀態之一: 'typing'
(初始)、'sending'
和 'sent'
。
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); }); }
您仍然可以宣告一些常數來提高可讀性。
const isSending = status === 'sending';
const isSent = status === 'sent';
但它們不是狀態變數,因此您不必擔心它們彼此不同步。
避免冗餘狀態
如果您可以元件的 props 或其現有的狀態變數在渲染過程中計算出某些資訊,您不應該將該資訊放入該元件的狀態中。
例如,使用這個表單。它可以運作,但您能在其中找到任何冗餘狀態嗎?
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> </> ); }
此表單具有三個狀態變數:firstName
、lastName
和 fullName
。但是,fullName
是冗餘的。您始終可以在渲染過程中從 firstName
和 lastName
計算出 fullName
,因此請將其從狀態中移除。
您可以這樣做:
import { useState } from 'react'; export default function Form() { const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const fullName = firstName + ' ' + lastName; function handleFirstNameChange(e) { setFirstName(e.target.value); } function handleLastNameChange(e) { setLastName(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> </> ); }
在這裡,fullName
_不是_ 狀態變數。相反,它是在渲染過程中計算出來的。
const fullName = firstName + ' ' + lastName;
因此,變更處理程式不需要執行任何特殊操作來更新它。當您呼叫 setFirstName
或 setLastName
時,您會觸發重新渲染,然後下一個 fullName
將根據新的資料計算出來。
深入探討
冗餘狀態的一個常見示例如下所示的程式碼:
function Message({ messageColor }) {
const [color, setColor] = useState(messageColor);
在這裡,color
狀態變數初始化為 messageColor
prop。問題是,如果父元件稍後傳遞不同的 messageColor
值(例如,'red'
而不是 'blue'
),則 color
_狀態變數_ 將不會更新! 狀態僅在第一次渲染期間初始化。
這就是為什麼在狀態變數中「鏡像」某些 prop 可能會導致混淆的原因。相反,請直接在您的程式碼中使用 messageColor
prop。如果您想給它一個較短的名稱,請使用常數。
function Message({ messageColor }) {
const color = messageColor;
這樣它就不會與從父元件傳遞的 prop 不同步。
僅當您 _想要_ 忽略特定 prop 的所有更新時,「鏡像」props 到狀態才有意義。按照慣例,prop 名稱以 initial
或 default
開頭,以闡明其新值將被忽略。
function Message({ initialColor }) {
// The `color` state variable holds the *first* value of `initialColor`.
// Further changes to the `initialColor` prop are ignored.
const [color, setColor] = useState(initialColor);
避免狀態重複
這個選單列表元件可讓您從多種旅行點心中選擇一種。
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] ); return ( <> <h2>What's your travel snack?</h2> <ul> {items.map(item => ( <li key={item.id}> {item.title} {' '} <button onClick={() => { setSelectedItem(item); }}>Choose</button> </li> ))} </ul> <p>You picked {selectedItem.title}.</p> </> ); }
目前,它將所選項目作為物件儲存在 selectedItem
狀態變數中。但是,這不太好:selectedItem
的內容與 items
列表中某個項目的物件相同。這表示項目本身的資訊在兩個地方重複了。
為什麼這是個問題?讓我們將每個項目都設為可編輯。
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> </> ); }
請注意,如果您先點擊某個項目上的「選擇」,_然後_ 編輯它,輸入會更新,但底部的標籤不會反映編輯內容。這是因為您有重複的狀態,並且您忘記更新 selectedItem
。
雖然您也可以更新 selectedItem
,但更簡單的解決方案是移除重複。在此範例中,您不是使用 selectedItem
物件(這會建立與 items
內物件的重複),而是在狀態中保存 selectedId
,_然後_ 通過搜尋具有該 ID 的項目的 items
陣列來取得 selectedItem
。
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> </> ); }
狀態曾經像這樣重複:
項目 = [{ id: 0, title: '椒鹽脆餅'}, ...]
selectedItem = {id: 0, title: '椒鹽脆餅'}
但在變更後會像這樣
項目 = [{ id: 0, title: '椒鹽脆餅'}, ...]
selectedId = 0
重複的部分消失了,您只保留了必要的狀態!
現在,如果您編輯*選定的*項目,下方的訊息將立即更新。這是因為 setItems
觸發了重新渲染,並且 items.find(...)
會找到標題已更新的項目。您不需要在狀態中保存*選定的項目*,因為只有*選定的 ID* 是必要的。其餘部分可以在渲染期間計算。
避免深度巢狀狀態
想像一個由行星、大陸和國家組成的旅行計劃。您可能會想要使用巢狀物件和陣列來 structuring 其狀態,如下例所示
export const initialTravelPlan = { id: 0, title: '(Root)', childPlaces: [{ id: 1, title: 'Earth', childPlaces: [{ id: 2, title: 'Africa', childPlaces: [{ id: 3, title: 'Botswana', childPlaces: [] }, { id: 4, title: 'Egypt', childPlaces: [] }, { id: 5, title: 'Kenya', childPlaces: [] }, { id: 6, title: 'Madagascar', childPlaces: [] }, { id: 7, title: 'Morocco', childPlaces: [] }, { id: 8, title: 'Nigeria', childPlaces: [] }, { id: 9, title: 'South Africa', childPlaces: [] }] }, { id: 10, title: 'Americas', childPlaces: [{ id: 11, title: 'Argentina', childPlaces: [] }, { id: 12, title: 'Brazil', childPlaces: [] }, { id: 13, title: 'Barbados', childPlaces: [] }, { id: 14, title: 'Canada', childPlaces: [] }, { id: 15, title: 'Jamaica', childPlaces: [] }, { id: 16, title: 'Mexico', childPlaces: [] }, { id: 17, title: 'Trinidad and Tobago', childPlaces: [] }, { id: 18, title: 'Venezuela', childPlaces: [] }] }, { id: 19, title: 'Asia', childPlaces: [{ id: 20, title: 'China', childPlaces: [] }, { id: 21, title: 'India', childPlaces: [] }, { id: 22, title: 'Singapore', childPlaces: [] }, { id: 23, title: 'South Korea', childPlaces: [] }, { id: 24, title: 'Thailand', childPlaces: [] }, { id: 25, title: 'Vietnam', childPlaces: [] }] }, { id: 26, title: 'Europe', childPlaces: [{ id: 27, title: 'Croatia', childPlaces: [], }, { id: 28, title: 'France', childPlaces: [], }, { id: 29, title: 'Germany', childPlaces: [], }, { id: 30, title: 'Italy', childPlaces: [], }, { id: 31, title: 'Portugal', childPlaces: [], }, { id: 32, title: 'Spain', childPlaces: [], }, { id: 33, title: 'Turkey', childPlaces: [], }] }, { id: 34, title: 'Oceania', childPlaces: [{ id: 35, title: 'Australia', childPlaces: [], }, { id: 36, title: 'Bora Bora (French Polynesia)', childPlaces: [], }, { id: 37, title: 'Easter Island (Chile)', childPlaces: [], }, { id: 38, title: 'Fiji', childPlaces: [], }, { id: 39, title: 'Hawaii (the USA)', childPlaces: [], }, { id: 40, title: 'New Zealand', childPlaces: [], }, { id: 41, title: 'Vanuatu', childPlaces: [], }] }] }, { id: 42, title: 'Moon', childPlaces: [{ id: 43, title: 'Rheita', childPlaces: [] }, { id: 44, title: 'Piccolomini', childPlaces: [] }, { id: 45, title: 'Tycho', childPlaces: [] }] }, { id: 46, title: 'Mars', childPlaces: [{ id: 47, title: 'Corn Town', childPlaces: [] }, { id: 48, title: 'Green Hill', childPlaces: [] }] }] };
現在,假設您要新增一個按鈕來刪除您已經去過的地方。您會怎麼做? 更新巢狀狀態 需要從變更的部分一路向上複製物件。刪除深度巢狀的地方將涉及複製其整個父級地方鏈。這樣的程式碼可能會非常冗長。
如果狀態巢狀太深而難以更新,請考慮將其「扁平化」。 您可以透過以下方式 restructuring 此資料。您可以讓每個地方保存一個*其子地方 ID* 的陣列,而不是每個 place
都有一個*其子地方* 的陣列的樹狀結構。然後儲存從每個地方 ID 到相應地方的映射。
這種資料 restructuring 可能會讓您想起資料庫表格
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] }, 3: { id: 3, title: 'Botswana', childIds: [] }, 4: { id: 4, title: 'Egypt', childIds: [] }, 5: { id: 5, title: 'Kenya', childIds: [] }, 6: { id: 6, title: 'Madagascar', childIds: [] }, 7: { id: 7, title: 'Morocco', childIds: [] }, 8: { id: 8, title: 'Nigeria', childIds: [] }, 9: { id: 9, title: 'South Africa', childIds: [] }, 10: { id: 10, title: 'Americas', childIds: [11, 12, 13, 14, 15, 16, 17, 18], }, 11: { id: 11, title: 'Argentina', childIds: [] }, 12: { id: 12, title: 'Brazil', childIds: [] }, 13: { id: 13, title: 'Barbados', childIds: [] }, 14: { id: 14, title: 'Canada', childIds: [] }, 15: { id: 15, title: 'Jamaica', childIds: [] }, 16: { id: 16, title: 'Mexico', childIds: [] }, 17: { id: 17, title: 'Trinidad and Tobago', childIds: [] }, 18: { id: 18, title: 'Venezuela', childIds: [] }, 19: { id: 19, title: 'Asia', childIds: [20, 21, 22, 23, 24, 25], }, 20: { id: 20, title: 'China', childIds: [] }, 21: { id: 21, title: 'India', childIds: [] }, 22: { id: 22, title: 'Singapore', childIds: [] }, 23: { id: 23, title: 'South Korea', childIds: [] }, 24: { id: 24, title: 'Thailand', childIds: [] }, 25: { id: 25, title: 'Vietnam', childIds: [] }, 26: { id: 26, title: 'Europe', childIds: [27, 28, 29, 30, 31, 32, 33], }, 27: { id: 27, title: 'Croatia', childIds: [] }, 28: { id: 28, title: 'France', childIds: [] }, 29: { id: 29, title: 'Germany', childIds: [] }, 30: { id: 30, title: 'Italy', childIds: [] }, 31: { id: 31, title: 'Portugal', childIds: [] }, 32: { id: 32, title: 'Spain', childIds: [] }, 33: { id: 33, title: 'Turkey', childIds: [] }, 34: { id: 34, title: 'Oceania', childIds: [35, 36, 37, 38, 39, 40, 41], }, 35: { id: 35, title: 'Australia', childIds: [] }, 36: { id: 36, title: 'Bora Bora (French Polynesia)', childIds: [] }, 37: { id: 37, title: 'Easter Island (Chile)', childIds: [] }, 38: { id: 38, title: 'Fiji', childIds: [] }, 39: { id: 40, title: 'Hawaii (the USA)', childIds: [] }, 40: { id: 40, title: 'New Zealand', childIds: [] }, 41: { id: 41, title: 'Vanuatu', childIds: [] }, 42: { id: 42, title: 'Moon', childIds: [43, 44, 45] }, 43: { id: 43, title: 'Rheita', childIds: [] }, 44: { id: 44, title: 'Piccolomini', childIds: [] }, 45: { id: 45, title: 'Tycho', childIds: [] }, 46: { id: 46, title: 'Mars', childIds: [47, 48] }, 47: { id: 47, title: 'Corn Town', childIds: [] }, 48: { id: 48, title: 'Green Hill', childIds: [] } };
現在狀態是「扁平的」(也稱為「正規化」),更新巢狀項目變得更容易。
為了現在刪除一個地方,您只需要更新兩個層級的狀態
- 其*父級*地方的更新版本應將已移除的 ID 從其
childIds
陣列中排除。 - 根「表格」物件的更新版本應包含父級地方的更新版本。
以下是如何進行的範例
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]; // Create a new version of the parent place // that doesn't include this child ID. const nextParent = { ...parent, childIds: parent.childIds .filter(id => id !== childId) }; // Update the root state object... setPlan({ ...plan, // ...so that it has the updated parent. [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> ); }
您可以根據需要巢狀狀態,但將其「扁平化」可以解決許多問題。它可以更輕鬆地更新狀態,並有助於確保巢狀物件的不同部分沒有重複。
深入探討
理想情況下,您也應該從「表格」物件中移除已刪除的項目(及其子項!)以改善記憶體使用量。這個版本會執行此操作。它也 使用 Immer 來簡化更新邏輯。
{ "dependencies": { "immer": "1.7.3", "react": "latest", "react-dom": "latest", "react-scripts": "latest", "use-immer": "0.5.1" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" }, "devDependencies": {} }
有時,您也可以透過將一些巢狀狀態移至子組件來減少狀態巢狀。這適用於不需要儲存的臨時 UI 狀態,例如項目是否已滑鼠懸停。
重點回顧
- 如果兩個狀態變數始終一起更新,請考慮將它們合併成一個。
- 仔細選擇您的狀態變數,以避免建立「不可能的」狀態。
- 以減少更新錯誤機會的方式來 structuring 您的狀態。
- 避免冗餘和重複的狀態,這樣您就不需要保持它們同步。
- 除非您特別想要防止更新,否則不要將 props 放入狀態中。
- 對於像選擇這樣的 UI 模式,請在狀態中保留 ID 或索引,而不是物件本身。
- 如果更新深度巢狀狀態很複雜,請嘗試將其扁平化。
挑戰 1之 4: 修復未更新的組件
這個 Clock
組件接收兩個 props:color
和 time
。當您在下拉式選單中選擇不同的顏色時,Clock
組件會從其父組件接收不同的 color
prop。但是,由於某種原因,顯示的顏色沒有更新。為什麼?請解決這個問題。
import { useState } from 'react'; export default function Clock(props) { const [color, setColor] = useState(props.color); return ( <h1 style={{ color: color }}> {props.time} </h1> ); }