状態を適切に構造化することで、変更やデバッグが容易なコンポーネントと、常にバグの原因となるコンポーネントとの違いが生まれます。状態を構造化する際に考慮すべきヒントをいくつか紹介します。
学習内容
- 単一の状態変数と複数の状態変数を使い分ける場合
- 状態を整理する際に避けるべきこと
- 状態構造に関する一般的な問題の修正方法
状態構造化の原則 {/* SVGアイコン */}
状態を持つコンポーネントを記述する場合、使用する状態変数の数とデータの形状について選択する必要があります。最適ではない状態構造であっても正しいプログラムを記述することは可能ですが、より良い選択をするための指針となる原則がいくつかあります。
- 関連する状態をグループ化する。 常に 2 つ以上の状態変数を同時に更新する場合は、それらを 1 つの状態変数にマージすることを検討してください。
- 状態の矛盾を避ける。 複数の状態が互いに矛盾し、「一致しない」可能性のある方法で状態が構造化されている場合、ミスが発生する可能性があります。これを避けるようにしてください。
- 冗長な状態を避ける。 レンダリング中にコンポーネントの props または既存の状態変数から情報を計算できる場合は、その情報をコンポーネントの状態に含めないでください。
- 状態の重複を避ける。 複数の状態変数間、またはネストされたオブジェクト内で同じデータが重複している場合、それらを同期させるのが困難になります。可能な限り重複を減らしてください。
- 深くネストされた状態を避ける。 深く階層化された状態は更新するのが不便です。可能な場合は、状態をフラットな方法で構造化することをお勧めします。
これらの原則の背後にある目標は、*ミスを発生させることなく状態を簡単に更新できるようにすること*です。状態から冗長なデータや重複したデータを削除することで、すべての状態が同期した状態に保たれます。これは、データベースエンジニアがバグ発生の可能性を減らすためにデータベース構造を「正規化」する方法に似ています。アルバート・アインシュタインの言葉を言い換えれば、「状態はできるだけシンプルにする必要があります。ただし、必要以上にシンプルにしてはいけません。」
では、これらの原則が実際にどのように適用されるかを見てみましょう。
関連する状態をグループ化する {/* SVGアイコン */}
単一の状態変数を使用するか、複数の状態変数を使用するか迷う場合があります。
このようにするべきでしょうか?
const [x, setX] = useState(0);
const [y, setY] = useState(0);
それともこのようにするべきでしょうか?
const [position, setPosition] = useState({ x: 0, y: 0 });
技術的には、どちらのアプローチを使用しても構いません。ただし、2 つの状態変数が常に一緒に変化する場合は、それらを 1 つの状態変数に統合することをお勧めします。 そうすれば、この例のように、カーソルを移動すると赤い点の両方の座標が更新されるため、常に同期していることを忘れることはありません。
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> ) }
データをオブジェクトまたは配列にグループ化するもう 1 つのケースは、必要な状態の数がわからない場合です。たとえば、ユーザーがカスタムフィールドを追加できるフォームがある場合に役立ちます。
状態の矛盾を避ける {/* SVGアイコン */}
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
になってはならないため、これらを_3つ_の有効な状態('typing'
(初期状態)、'sending'
、'sent'
)のいずれかを取る1つの 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); }); }
可読性のために、いくつかの定数を宣言することもできます。
const isSending = status === 'sending';
const isSent = status === 'sent';
しかし、これらは状態変数ではないため、互いに同期がずれることを心配する必要はありません。
冗長な状態を避ける
レンダリング中にコンポーネントのプロップまたは既存の状態変数から情報を計算できる場合、その情報をコンポーネントの状態に含めるべきではありません。
たとえば、このフォームを考えてみましょう。これは機能しますが、冗長な状態を見つけられますか?
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
の3つの状態変数があります。ただし、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
プロップに初期化されています。問題は、親コンポーネントが後で異なる値のmessageColor
(たとえば、'blue'
の代わりに'red'
)を渡した場合、color
_状態変数_は更新されないことです!状態は最初のレンダリング中にのみ初期化されます。
これが、状態変数でプロップを「ミラーリング」すると混乱を招く可能性がある理由です。代わりに、コードでmessageColor
プロップを直接使用してください。短い名前を付けたい場合は、定数を使用してください。
function Message({ messageColor }) {
const color = messageColor;
こうすることで、親コンポーネントから渡されたプロップと同期しなくなることはありません。
状態にプロップを「ミラーリング」することは、特定のプロップのすべての更新を_意図的に_無視したい場合にのみ意味があります。慣例により、プロップ名の先頭に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);
状態の重複を避ける
このメニューリストコンポーネントでは、いくつかの旅行用スナックから1つを選択できます。
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
リスト内のアイテムの1つと同じオブジェクトです。これは、アイテム自体の情報が2か所に重複していることを意味します。
なぜこれが問題なのでしょうか?各アイテムを編集可能にしてみましょう。
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> </> ); }
以前は状態が次のように重複していました。
items = [{ id: 0, title: 'pretzels'}, ...]
selectedItem = {id: 0, title: 'pretzels'}
しかし、変更後は次のようになります。
items = [{ id: 0, title: 'pretzels'}, ...]
selectedId = 0
重複がなくなり、必要な状態のみを保持します!
選択した項目を編集すると、以下のメッセージが即座に更新されます。これは、`setItems` が再レンダリングをトリガーし、`items.find(...)` が更新されたタイトルを持つ項目を見つけるためです。_選択された項目_を状態として保持する必要はありませんでした。なぜなら、_選択されたID_のみが必須だからです。残りはレンダリング中に計算できます。
深くネストされた状態は避けてください
惑星、大陸、国からなる旅行プランを想像してみてください。この例のように、ネストされたオブジェクトと配列を使用して状態を構造化したいと思うかもしれません。
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: [] }] }] };
さて、既に訪れた場所を削除するためのボタンを追加したいとしましょう。どのようにすればよいでしょうか?_ネストされた状態の更新_には、変更された部分から上方向にオブジェクトのコピーを作成することが含まれます。深くネストされた場所を削除するには、その親の場所チェーン全体をコピーする必要があります。このようなコードは非常に冗長になる可能性があります。
**状態が複雑にネストされすぎて更新が難しい場合は、「フラット化」することを検討してください。** このデータを再構築する方法の1つを次に示します。各 `place` に _その子の場所_ の配列が含まれるツリーのような構造ではなく、各場所に _その子の場所ID_ の配列を含めることができます。そして、各場所IDから対応する場所へのマッピングを保存します。
このデータの再構築は、データベーステーブルを見たことを思い起こさせるかもしれません。
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: [] } };
状態が「フラット」(「正規化」とも呼ばれます)になったので、ネストされた項目の更新が容易になります。
場所を削除するには、2つのレベルの状態を更新するだけで済みます。
- 更新されたバージョンの _親_ の場所は、削除された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状態に適しています。
まとめ // 省略
- 2つの状態変数が常に一緒に更新される場合は、1つにマージすることを検討してください。
- 「不可能な」状態を作成しないように、状態変数を慎重に選択してください。
- 更新時にミスをする可能性を減らすように状態を構造化してください。
- 冗長な状態や重複した状態を避けて、同期を保つ必要がないようにしてください。
- 更新を明示的に防ぎたい場合を除き、小道具を状態に _入れない_ でください。
- 選択などのUIパターンでは、オブジェクト自体ではなく、IDまたはインデックスを状態に保持します。
- 深くネストされた状態の更新が複雑な場合は、フラット化を試してください。
いくつかの課題に挑戦してみましょう // 省略
チャレンジ 1の 4: 更新されないコンポーネントを修正する // 省略
この `Clock` コンポーネントは、`color` と `time` の2つの小道具を受け取ります。セレクトボックスで別の色を選択すると、`Clock` コンポーネントは親コンポーネントから別の `color` 小道具を受け取ります。ただし、何らかの理由で、表示される色が更新されません。なぜでしょうか?問題を修正してください。
import { useState } from 'react'; export default function Clock(props) { const [color, setColor] = useState(props.color); return ( <h1 style={{ color: color }}> {props.time} </h1> ); }