state 構造の選択
快適に変更やデバッグが行えるコンポーネントと、常にバグの種になるコンポーネントの違いは、state をうまく構造化できているかどうかです。ここでは、state 構造を考慮する際に役立つ、いくつかのヒントをご紹介します。
このページで学ぶこと
- 単一の state 変数と複数の state 変数の使い分け
- state の構成において避けるべきこと
- state 構造の一般的な問題を修正する方法
state 構造の原則
state を格納するコンポーネントを作成する際に、いくつ state 変数を使うのか、データ構造をどのようにするのかについて選択を行う必要があります。最適とはいえない state 構造でも正しいプログラムを作成することは可能ではありますが、より良い選択をするために役立つ原則がいくつか存在します。
- 関連する state をグループ化する。2 つ以上の state 変数を常に同時に更新する場合、それらを単一の state 変数にまとめることを検討してください。
- state の矛盾を避ける。state の複数部分が矛盾して互いに「衝突する」構造になっている場合、ミスが発生する余地があるということです。これを避けてください。
- 冗長な state を避ける。コンポーネントの props や既存の state 変数からレンダー時に何らかの情報を計算できる場合、その情報をコンポーネントの state に入れるべきではありません。
- state 内の重複を避ける。同じデータが複数の state 変数間、またはネストしたオブジェクト間で重複している場合、それらを同期させることは困難です。できる限り重複を減らしてください。
- 深くネストされた state を避ける。深い階層構造となっている state はあまり更新しやすくありません。できる限り、state をフラットに構造化する方法を選ぶようにしてください。
これらの原則の背後にある目標は、ミスを入りこませずに state を容易に更新できるようにすることです。state から冗長で重複するデータを取り除くことで、すべての state が同期した状態を保てるようになります。これは、データベースエンジニアがバグを減らすためにデータベース構造を “正規化 (normalize)” しようとする考え方と似ています。アルバート・アインシュタインのもじりですが、「state はできる限りシンプルにすべきだ、だがシンプルすぎてもいけない」ということです。
これらの原則が実際にどのように適用されるか見てみましょう。
関連する state をグループ化する
ときに、単一の state 変数を使用するか、複数の state 変数を使用するかで迷うことがあるかもしれません。
こうすべきでしょうか?
const [x, setX] = useState(0);
const [y, setY] = useState(0);
それともこうでしょうか?
const [position, setPosition] = useState({ x: 0, y: 0 });
技術的には、どちらのアプローチを採用することも可能です。しかし 2 つの state 変数が常に一緒に変更される場合は、それらを単一の state 変数にまとめると良いでしょう。そうすれば、常に両者を同期することを忘れる心配がありません。例えば以下に、カーソルを動かすと赤い点の両方の軸の座標が更新されるという例を示します。
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> ) }
state をオブジェクトや配列にグループ化する別のケースとして、state の数が事前にわからない場合があります。たとえば、ユーザがカスタムフィールドを追加できるフォームがある場合に、これが有用です。
state の矛盾を避ける
以下は、isSending
と isSent
という state 変数があるホテルのフィードバックフォームです。
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); }); }
このコードは機能しますが、「ありえない」state になってしまう余地を残しています。たとえば、setIsSent
と setIsSending
を一緒に呼び出すのを忘れた場合、isSending
と isSent
が同時に true
になってしまう状況に陥るかもしれません。コンポーネントが複雑になればなるほど、何が起こったのか理解しにくくなります。
isSending
と isSent
は同時に true
になることはないため、それらを 1 つの status
という state 変数に置き換えて、typing
(初期状態)、sending
、sent
という 3 つの有効な状態のうちの 1 つになるようにする方が良いでしょう。
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';
これらは state 変数ではなくなったので、互いに同期がとれなくなる心配をする必要はありません。
冗長な state を避ける
レンダー中にコンポーネントの props や既存の state 変数から情報を計算できる場合、その情報をコンポーネントの state に入れるべきではありません。
例として、このフォームを見てみましょう。動作はしていますが、冗長な state がないか探してみてください。
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> </> ); }
このフォームには 3 つの state 変数があります。firstName
、lastName
、そして fullName
です。しかし、fullName
は冗長です。レンダー中に fullName
は常に firstName
と lastName
から計算できるので、state から削除しましょう。
以下のようにします。
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
は state 変数ではなくなっています。代わりに、レンダー中に計算されます:
const fullName = firstName + ' ' + lastName;
結果的に、これを更新するために change ハンドラは何も特別なことをする必要がなくなりました。setFirstName
や setLastName
を呼び出すと、再レンダーがトリガされ、次の fullName
は新しいデータから計算し直されます。
さらに深く知る
冗長な state の一般的な例として、このようなコードがあります:
function Message({ messageColor }) {
const [color, setColor] = useState(messageColor);
ここでは、color
という state 変数が props である messageColor
の値で初期化されています。問題は、親コンポーネントが後で異なる messageColor
値(例えば 'blue'
から 'red'
)を渡してきた場合、state 変数である color
の方は更新されないということです! state は最初のレンダー時にのみ初期化されます。
これが、props を state 変数に「コピー」することが混乱を招く理由です。代わりに、messageColor
をコードで直接使用してください。短い名前にしたい場合は、定数を使用してください:
function Message({ messageColor }) {
const color = messageColor;
これにより、親コンポーネントから渡された props と同期されなくなってしまうことを防げます。
props を state に「コピー」することが意味を持つのは、特定の props のすべての更新を意図的に無視したい場合だけです。慣習として、新しい値が来ても無視されるということを明確にしたい場合は、props の名前を 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);
state 内の重複を避ける
このメニューリストコンポーネントでは、旅行に持っていくお菓子を複数の選択肢から 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
という state 変数にオブジェクトとして格納しています。しかしこれは良くありません。なぜなら、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> </> ); }
いずれかの項目の “Choose” をクリックしてから編集すると、入力欄は更新されますが、下部のラベルは編集内容を反映していません。これは、state に重複があり、selectedItem
側の更新を忘れたためです。
selectedItem
側も更新するようにしても良いのですが、簡単な解決策は重複を解消することです。この例では、items
内のオブジェクトと selectedItem
オブジェクトを重複させる代わりに、state では 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> </> ); }
以前は state がこのように重複していました。
items = [{ id: 0, title: 'pretzels'}, ...]
selectedItem = {id: 0, title: 'pretzels'}
しかし、変更後は以下のようになります。
items = [{ id: 0, title: 'pretzels'}, ...]
selectedId = 0
重複がなくなり、必要な state だけが残っています!
これで、選択された項目を編集すると、下のメッセージもすぐに更新されるようになります。これは、setItems
が再レンダーをトリガし、items.find(...)
がタイトル更新後の項目を見つけてくるためです。選択された項目のデータ全体を state に格納する必要はありませんでした。なぜなら選択された項目 ID だけが本質的なものだからです。残りはレンダー時に計算することができます。
深くネストされた state を避ける
惑星、大陸、国々で構成される旅行計画を想像してみてください。以下のようにして、state をネストしたオブジェクトと配列を駆使して構造化することが魅力的に思えるかもしれません。
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: [] }] }] };
ここで、既に訪れた場所を削除するボタンを追加したくなったとしましょう。どのようにすればよいでしょうか? ネストされた state を更新すると、変更された部分より上のすべてのオブジェクトのコピーを作成する必要が出てきます。深くネストされたところにある場所情報を削除するためには、親として繋がっている場所データをすべてコピーする必要があります。そのようなコードを書くのはとても大変です。
state が簡単に更新できないほどネストしている場合は、「フラット」にすることを検討してください。ここでは、データを再構築する方法の 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: [] } };
state が「フラット」な(別名「正規化された (normalized)」)状態になったので、ネストされた項目の更新が簡単になります。
場所を削除したい場合、state を 2 レベルにわたって更新するだけで済みます。
- 親の場所情報を更新して、
childIds
配列から、削除された場所の ID を除外する。 - ルートの「テーブル」オブジェクトを更新して、上記の更新された親の場所情報を含むようにする。
以下がやり方の一例です。
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> ); }
state は好きなだけネストさせることができますが、「フラット」にすることで多くの問題を解決できます。state の更新が容易になるだけでなく、ネストされたオブジェクトのさまざまな部分で重複がないことを保証するのにも役立ちます。
さらに深く知る
理想的には、削除された場所アイテム(およびその子アイテム!)自体も「テーブル」オブジェクトから削除して、メモリ使用量を改善するとよいでしょう。以下のバージョンはそれを行うものです。また、アップデートロジックをより簡潔にするために 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": {} }
ネストされている state の一部を子コンポーネントに移動することで、state のネストを減らすことが可能な場合もあります。これは「アイテムがホバーされているか」といった、保存する必要のない一時的な UI 関連の state で適しています。
まとめ
- 2 つの state 変数が常に一緒に更新される場合は、それらを 1 つにまとめることを検討する。
- 「ありえない」state を作成しないよう、state 変数を注意深く選択する。
- state は、更新時に間違いが発生しづらいやり方で構成する。
- 冗長で重複した state を避け、同期する必要がないようにする。
- 意図的に更新されないようにしたい場合を除き、props を state にコピーしない。
- 項目選択のような UI パターンにおいては、state にオブジェクト自体ではなく ID またはインデックスを保持する。
- 深くネストされた state の更新が複雑な場合は、フラット化を試す。
チャレンジ 1/4: 更新されないコンポーネントの修正
この Clock
コンポーネントは、color
と time
の 2 つの props を受け取ります。セレクトボックスで別の色を選択すると、Clock
コンポーネントは親コンポーネントから props として異なる color
を受け取るようになっています。しかし、何らかの理由で表示される色が更新されません。なぜでしょうか? 問題を修正してください。
import { useState } from 'react'; export default function Clock(props) { const [color, setColor] = useState(props.color); return ( <h1 style={{ color: color }}> {props.time} </h1> ); }