| title | 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 変数を使用するかで迷うことがあるかもしれません。
こうすべきでしょうか?
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>
)
}body { margin: 0; padding: 0; height: 250px; }state をオブジェクトや配列にグループ化する別のケースとして、state の数が事前にわからない場合があります。たとえば、ユーザがカスタムフィールドを追加できるフォームがある場合に、これが有用です。
state 変数がオブジェクトの場合、1 つのフィールドだけを更新することはできず、他のフィールドも明示的にコピーする必要があることを思い出してください。たとえば、上記の例で setPosition({ x: 100 }) とはできません。y プロパティが全くないからです! x だけを設定したい場合、setPosition({ ...position, x: 100 }) とするか、2 つの state 変数に分割して setX(100) とするかのどちらかになります。
以下は、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 変数ではなくなったので、互いに同期がとれなくなる心配をする必要はありません。
レンダー中にコンポーネントの 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>
</>
);
}label { display: block; margin-bottom: 5px; }このフォームには 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>
</>
);
}label { display: block; margin-bottom: 5px; }これで 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);このメニューリストコンポーネントでは、旅行に持っていくお菓子を複数の選択肢から 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>
</>
);
}button { margin-top: 10px; }現在、選択した項目を 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>
</>
);
}button { margin-top: 10px; }いずれかの項目の "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>
</>
);
}button { margin-top: 10px; }以前は 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 をネストしたオブジェクトと配列を駆使して構造化することが魅力的に思えるかもしれません。
import { useState } from 'react';
import { initialTravelPlan } from './places.js';
function PlaceTree({ place }) {
const childPlaces = place.childPlaces;
return (
<li>
{place.title}
{childPlaces.length > 0 && (
<ol>
{childPlaces.map(place => (
<PlaceTree key={place.id} place={place} />
))}
</ol>
)}
</li>
);
}
export default function TravelPlan() {
const [plan, setPlan] = useState(initialTravelPlan);
const planets = plan.childPlaces;
return (
<>
<h2>Places to visit</h2>
<ol>
{planets.map(place => (
<PlaceTree key={place.id} place={place} />
))}
</ol>
</>
);
}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 と対応する場所情報のマッピングを格納します。
このようなデータ再構成を見ると、データベーステーブルを思い出すかもしれませんね。
import { useState } from 'react';
import { initialTravelPlan } from './places.js';
function PlaceTree({ id, placesById }) {
const place = placesById[id];
const childIds = place.childIds;
return (
<li>
{place.title}
{childIds.length > 0 && (
<ol>
{childIds.map(childId => (
<PlaceTree
key={childId}
id={childId}
placesById={placesById}
/>
))}
</ol>
)}
</li>
);
}
export default function TravelPlan() {
const [plan, setPlan] = useState(initialTravelPlan);
const root = plan[0];
const planetIds = root.childIds;
return (
<>
<h2>Places to visit</h2>
<ol>
{planetIds.map(id => (
<PlaceTree
key={id}
id={id}
placesById={plan}
/>
))}
</ol>
</>
);
}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>
);
}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: 39,
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: []
}
};button { margin: 10px; }state は好きなだけネストさせることができますが、「フラット」にすることで多くの問題を解決できます。state の更新が容易になるだけでなく、ネストされたオブジェクトのさまざまな部分で重複がないことを保証するのにも役立ちます。
理想的には、削除された場所アイテム(およびその子アイテム!)自体も「テーブル」オブジェクトから削除して、メモリ使用量を改善するとよいでしょう。以下のバージョンはそれを行うものです。また、アップデートロジックをより簡潔にするために Immer を使用しています。
import { useImmer } from 'use-immer';
import { initialTravelPlan } from './places.js';
export default function TravelPlan() {
const [plan, updatePlan] = useImmer(initialTravelPlan);
function handleComplete(parentId, childId) {
updatePlan(draft => {
// Remove from the parent place's child IDs.
const parent = draft[parentId];
parent.childIds = parent.childIds
.filter(id => id !== childId);
// Forget this place and all its subtree.
deleteAllChildren(childId);
function deleteAllChildren(id) {
const place = draft[id];
place.childIds.forEach(deleteAllChildren);
delete draft[id];
}
});
}
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>
);
}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: 39,
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: []
}
};button { margin: 10px; }{
"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"
}
}ネストされている state の一部を子コンポーネントに移動することで、state のネストを減らすことが可能な場合もあります。これは「アイテムがホバーされているか」といった、保存する必要のない一時的な UI 関連の state で適しています。
- 2 つの state 変数が常に一緒に更新される場合は、それらを 1 つにまとめることを検討する。
- 「ありえない」state を作成しないよう、state 変数を注意深く選択する。
- state は、更新時に間違いが発生しづらいやり方で構成する。
- 冗長で重複した state を避け、同期する必要がないようにする。
- 意図的に更新されないようにしたい場合を除き、props を state にコピーしない。
- 項目選択のような UI パターンにおいては、state にオブジェクト自体ではなく ID またはインデックスを保持する。
- 深くネストされた state の更新が複雑な場合は、フラット化を試す。
この 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>
);
}import { useState, useEffect } from 'react';
import Clock from './Clock.js';
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}
export default function App() {
const time = useTime();
const [color, setColor] = useState('lightcoral');
return (
<div>
<p>
Pick a color:{' '}
<select value={color} onChange={e => setColor(e.target.value)}>
<option value="lightcoral">lightcoral</option>
<option value="midnightblue">midnightblue</option>
<option value="rebeccapurple">rebeccapurple</option>
</select>
</p>
<Clock color={color} time={time.toLocaleTimeString()} />
</div>
);
}この問題は、このコンポーネントが color という state を color という props の初期値を使って初期化していることが原因です。しかし、props としての color が変更されても state 変数の方は影響されません! それらが同期されなくなってしまっているわけです。この問題を解決するには state 変数自体を削除し、props としての color を直接使用します。
import { useState } from 'react';
export default function Clock(props) {
return (
<h1 style={{ color: props.color }}>
{props.time}
</h1>
);
}import { useState, useEffect } from 'react';
import Clock from './Clock.js';
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}
export default function App() {
const time = useTime();
const [color, setColor] = useState('lightcoral');
return (
<div>
<p>
Pick a color:{' '}
<select value={color} onChange={e => setColor(e.target.value)}>
<option value="lightcoral">lightcoral</option>
<option value="midnightblue">midnightblue</option>
<option value="rebeccapurple">rebeccapurple</option>
</select>
</p>
<Clock color={color} time={time.toLocaleTimeString()} />
</div>
);
}または分割代入構文を使用して以下のようにします。
import { useState } from 'react';
export default function Clock({ color, time }) {
return (
<h1 style={{ color: color }}>
{time}
</h1>
);
}import { useState, useEffect } from 'react';
import Clock from './Clock.js';
function useTime() {
const [time, setTime] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id);
}, []);
return time;
}
export default function App() {
const time = useTime();
const [color, setColor] = useState('lightcoral');
return (
<div>
<p>
Pick a color:{' '}
<select value={color} onChange={e => setColor(e.target.value)}>
<option value="lightcoral">lightcoral</option>
<option value="midnightblue">midnightblue</option>
<option value="rebeccapurple">rebeccapurple</option>
</select>
</p>
<Clock color={color} time={time.toLocaleTimeString()} />
</div>
);
}この荷物 (packing) リストには、梱包済みアイテム数と全体のアイテム数を表示するフッタがあります。最初はうまく機能するように見えますが、バグがあります。例えば、アイテムに梱包済みとマークしてから削除しても、カウンタが正しく更新されません。カウンタが常に正確になるように修正してください。
この例の state のどれかは冗長ではないでしょうか?
import { useState } from 'react';
import AddItem from './AddItem.js';
import PackingList from './PackingList.js';
let nextId = 3;
const initialItems = [
{ id: 0, title: 'Warm socks', packed: true },
{ id: 1, title: 'Travel journal', packed: false },
{ id: 2, title: 'Watercolors', packed: false },
];
export default function TravelPlan() {
const [items, setItems] = useState(initialItems);
const [total, setTotal] = useState(3);
const [packed, setPacked] = useState(1);
function handleAddItem(title) {
setTotal(total + 1);
setItems([
...items,
{
id: nextId++,
title: title,
packed: false
}
]);
}
function handleChangeItem(nextItem) {
if (nextItem.packed) {
setPacked(packed + 1);
} else {
setPacked(packed - 1);
}
setItems(items.map(item => {
if (item.id === nextItem.id) {
return nextItem;
} else {
return item;
}
}));
}
function handleDeleteItem(itemId) {
setTotal(total - 1);
setItems(
items.filter(item => item.id !== itemId)
);
}
return (
<>
<AddItem
onAddItem={handleAddItem}
/>
<PackingList
items={items}
onChangeItem={handleChangeItem}
onDeleteItem={handleDeleteItem}
/>
<hr />
<b>{packed} out of {total} packed!</b>
</>
);
}import { useState } from 'react';
export default function AddItem({ onAddItem }) {
const [title, setTitle] = useState('');
return (
<>
<input
placeholder="Add item"
value={title}
onChange={e => setTitle(e.target.value)}
/>
<button onClick={() => {
setTitle('');
onAddItem(title);
}}>Add</button>
</>
)
}import { useState } from 'react';
export default function PackingList({
items,
onChangeItem,
onDeleteItem
}) {
return (
<ul>
{items.map(item => (
<li key={item.id}>
<label>
<input
type="checkbox"
checked={item.packed}
onChange={e => {
onChangeItem({
...item,
packed: e.target.checked
});
}}
/>
{' '}
{item.title}
</label>
<button onClick={() => onDeleteItem(item.id)}>
Delete
</button>
</li>
))}
</ul>
);
}button { margin: 5px; }
li { list-style-type: none; }
ul, li { margin: 0; padding: 0; }それぞれのイベントハンドラを注意深く書き換えて total と packed カウンタが正しく更新されるようにすることも可能ですが、根本的な問題は、そもそもこれらの state 変数が存在しているということです。items 配列自体からアイテム数(梱包済み・合計のいずれも)は常に計算できるので、そもそもこれらは不要です。冗長な state を削除してバグを修正しましょう。
import { useState } from 'react';
import AddItem from './AddItem.js';
import PackingList from './PackingList.js';
let nextId = 3;
const initialItems = [
{ id: 0, title: 'Warm socks', packed: true },
{ id: 1, title: 'Travel journal', packed: false },
{ id: 2, title: 'Watercolors', packed: false },
];
export default function TravelPlan() {
const [items, setItems] = useState(initialItems);
const total = items.length;
const packed = items
.filter(item => item.packed)
.length;
function handleAddItem(title) {
setItems([
...items,
{
id: nextId++,
title: title,
packed: false
}
]);
}
function handleChangeItem(nextItem) {
setItems(items.map(item => {
if (item.id === nextItem.id) {
return nextItem;
} else {
return item;
}
}));
}
function handleDeleteItem(itemId) {
setItems(
items.filter(item => item.id !== itemId)
);
}
return (
<>
<AddItem
onAddItem={handleAddItem}
/>
<PackingList
items={items}
onChangeItem={handleChangeItem}
onDeleteItem={handleDeleteItem}
/>
<hr />
<b>{packed} out of {total} packed!</b>
</>
);
}import { useState } from 'react';
export default function AddItem({ onAddItem }) {
const [title, setTitle] = useState('');
return (
<>
<input
placeholder="Add item"
value={title}
onChange={e => setTitle(e.target.value)}
/>
<button onClick={() => {
setTitle('');
onAddItem(title);
}}>Add</button>
</>
)
}import { useState } from 'react';
export default function PackingList({
items,
onChangeItem,
onDeleteItem
}) {
return (
<ul>
{items.map(item => (
<li key={item.id}>
<label>
<input
type="checkbox"
checked={item.packed}
onChange={e => {
onChangeItem({
...item,
packed: e.target.checked
});
}}
/>
{' '}
{item.title}
</label>
<button onClick={() => onDeleteItem(item.id)}>
Delete
</button>
</li>
))}
</ul>
);
}button { margin: 5px; }
li { list-style-type: none; }
ul, li { margin: 0; padding: 0; }この変更により、それぞれのイベントハンドラは setItems を呼び出すことだけを考えればよくなったことに注目しましょう。アイテム数は次のレンダー時に items から計算されるため、常に最新情報が表示されます。
手紙のリストである letters が state に保持されています。手紙のどれかにホバーまたはフォーカスすると、その手紙がハイライトされるようになっています。現在ハイライト中の手紙は、highlightedLetter という state 変数に格納されています。個々の手紙に対して "Star" や "Unstar"(スター解除)ができ、それにより state である letters 配列が更新されます。
機能はしていますが、このコードには小さな UI の不具合があります。"Star" や "Unstar" を押すと、一瞬だけハイライトが消えてしまうのです。ただしポインタを動かすか、キーボードで別の手紙に切り替えるとすぐにハイライトは再表示されます。なぜこれが起こるのでしょうか? ボタンクリック後にハイライトが消えないように修正してください。
import { useState } from 'react';
import { initialLetters } from './data.js';
import Letter from './Letter.js';
export default function MailClient() {
const [letters, setLetters] = useState(initialLetters);
const [highlightedLetter, setHighlightedLetter] = useState(null);
function handleHover(letter) {
setHighlightedLetter(letter);
}
function handleStar(starred) {
setLetters(letters.map(letter => {
if (letter.id === starred.id) {
return {
...letter,
isStarred: !letter.isStarred
};
} else {
return letter;
}
}));
}
return (
<>
<h2>Inbox</h2>
<ul>
{letters.map(letter => (
<Letter
key={letter.id}
letter={letter}
isHighlighted={
letter === highlightedLetter
}
onHover={handleHover}
onToggleStar={handleStar}
/>
))}
</ul>
</>
);
}export default function Letter({
letter,
isHighlighted,
onHover,
onToggleStar,
}) {
return (
<li
className={
isHighlighted ? 'highlighted' : ''
}
onFocus={() => {
onHover(letter);
}}
onPointerMove={() => {
onHover(letter);
}}
>
<button onClick={() => {
onToggleStar(letter);
}}>
{letter.isStarred ? 'Unstar' : 'Star'}
</button>
{letter.subject}
</li>
)
}export const initialLetters = [{
id: 0,
subject: 'Ready for adventure?',
isStarred: true,
}, {
id: 1,
subject: 'Time to check in!',
isStarred: false,
}, {
id: 2,
subject: 'Festival Begins in Just SEVEN Days!',
isStarred: false,
}];button { margin: 5px; }
li { border-radius: 5px; }
.highlighted { background: #d2eaff; }問題は、highlightedLetter に手紙オブジェクトを保持していることです。しかし同じ情報を letters 配列にも保持しています。つまり state に重複があるのです! ボタンクリックに応じて letters 配列を更新する際に、highlightedLetter とは異なる新しい手紙データオブジェクトが作成されます。これが、highlightedLetter === letter チェックが false になってハイライトが消えてしまう理由です。次にポインタが移動して setHighlightedLetter が呼び出されるとハイライトは再表示されます。
この問題を解決するには、state から重複を削除します。2 つの場所でその手紙データ自体を格納する代わりに、highlightedId を格納するようにします。これにより、letter.id === highlightedId とすることで、前回のレンダーから letter オブジェクトが変更された場合でも、各手紙の isHighlighted をチェックできます。
import { useState } from 'react';
import { initialLetters } from './data.js';
import Letter from './Letter.js';
export default function MailClient() {
const [letters, setLetters] = useState(initialLetters);
const [highlightedId, setHighlightedId ] = useState(null);
function handleHover(letterId) {
setHighlightedId(letterId);
}
function handleStar(starredId) {
setLetters(letters.map(letter => {
if (letter.id === starredId) {
return {
...letter,
isStarred: !letter.isStarred
};
} else {
return letter;
}
}));
}
return (
<>
<h2>Inbox</h2>
<ul>
{letters.map(letter => (
<Letter
key={letter.id}
letter={letter}
isHighlighted={
letter.id === highlightedId
}
onHover={handleHover}
onToggleStar={handleStar}
/>
))}
</ul>
</>
);
}export default function Letter({
letter,
isHighlighted,
onHover,
onToggleStar,
}) {
return (
<li
className={
isHighlighted ? 'highlighted' : ''
}
onFocus={() => {
onHover(letter.id);
}}
onPointerMove={() => {
onHover(letter.id);
}}
>
<button onClick={() => {
onToggleStar(letter.id);
}}>
{letter.isStarred ? 'Unstar' : 'Star'}
</button>
{letter.subject}
</li>
)
}export const initialLetters = [{
id: 0,
subject: 'Ready for adventure?',
isStarred: true,
}, {
id: 1,
subject: 'Time to check in!',
isStarred: false,
}, {
id: 2,
subject: 'Festival Begins in Just SEVEN Days!',
isStarred: false,
}];button { margin: 5px; }
li { border-radius: 5px; }
.highlighted { background: #d2eaff; }この例では、各 Letter(手紙)に props として、それが選択中かという情報である isSelected と onToggle ハンドラが渡されています。機能していますが、state が selectedId(null または ID のどちらか)という形で格納されているため、一度に 1 つの手紙しか選択できません。
state 構造を変更し、複数選択に対応させてください。(どのように構造化しますか? コードを書く前に考えてみてください。)各チェックボックスは他のチェックボックスとは独立して動作するようにしてください。選択中の手紙をクリックすると、チェックが外れるようにしてください。最後に、フッタに選択された項目数が正しく表示されるようにしてください。
単一の選択中 ID の代わりに、選択された ID の配列または Set を state に保持することができます。
import { useState } from 'react';
import { letters } from './data.js';
import Letter from './Letter.js';
export default function MailClient() {
const [selectedId, setSelectedId] = useState(null);
// TODO: allow multiple selection
const selectedCount = 1;
function handleToggle(toggledId) {
// TODO: allow multiple selection
setSelectedId(toggledId);
}
return (
<>
<h2>Inbox</h2>
<ul>
{letters.map(letter => (
<Letter
key={letter.id}
letter={letter}
isSelected={
// TODO: allow multiple selection
letter.id === selectedId
}
onToggle={handleToggle}
/>
))}
<hr />
<p>
<b>
You selected {selectedCount} letters
</b>
</p>
</ul>
</>
);
}export default function Letter({
letter,
onToggle,
isSelected,
}) {
return (
<li className={
isSelected ? 'selected' : ''
}>
<label>
<input
type="checkbox"
checked={isSelected}
onChange={() => {
onToggle(letter.id);
}}
/>
{letter.subject}
</label>
</li>
)
}export const letters = [{
id: 0,
subject: 'Ready for adventure?',
isStarred: true,
}, {
id: 1,
subject: 'Time to check in!',
isStarred: false,
}, {
id: 2,
subject: 'Festival Begins in Just SEVEN Days!',
isStarred: false,
}];input { margin: 5px; }
li { border-radius: 5px; }
label { width: 100%; padding: 5px; display: inline-block; }
.selected { background: #d2eaff; }1 つの selectedId の代わりに、state で selectedIds という配列を保持するようにします。例えば、最初と最後の手紙を選択したときに [0, 2] となるようにします。何も選択されていない場合は、空の [] という配列になります。
import { useState } from 'react';
import { letters } from './data.js';
import Letter from './Letter.js';
export default function MailClient() {
const [selectedIds, setSelectedIds] = useState([]);
const selectedCount = selectedIds.length;
function handleToggle(toggledId) {
// Was it previously selected?
if (selectedIds.includes(toggledId)) {
// Then remove this ID from the array.
setSelectedIds(selectedIds.filter(id =>
id !== toggledId
));
} else {
// Otherwise, add this ID to the array.
setSelectedIds([
...selectedIds,
toggledId
]);
}
}
return (
<>
<h2>Inbox</h2>
<ul>
{letters.map(letter => (
<Letter
key={letter.id}
letter={letter}
isSelected={
selectedIds.includes(letter.id)
}
onToggle={handleToggle}
/>
))}
<hr />
<p>
<b>
You selected {selectedCount} letters
</b>
</p>
</ul>
</>
);
}export default function Letter({
letter,
onToggle,
isSelected,
}) {
return (
<li className={
isSelected ? 'selected' : ''
}>
<label>
<input
type="checkbox"
checked={isSelected}
onChange={() => {
onToggle(letter.id);
}}
/>
{letter.subject}
</label>
</li>
)
}export const letters = [{
id: 0,
subject: 'Ready for adventure?',
isStarred: true,
}, {
id: 1,
subject: 'Time to check in!',
isStarred: false,
}, {
id: 2,
subject: 'Festival Begins in Just SEVEN Days!',
isStarred: false,
}];input { margin: 5px; }
li { border-radius: 5px; }
label { width: 100%; padding: 5px; display: inline-block; }
.selected { background: #d2eaff; }配列を使うことの小さな欠点の 1 つは、項目ごとに selectedIds.includes(letter.id) を呼び出して選択中かどうかを確認していることです。配列が非常に大きい場合 includes() での配列検索にはリニアに時間がかかり、またこの検索は個々の項目ごとに行われているため、パフォーマンスの問題になることがあります。
これを解決するためには、代わりに state で Set を保持するようにできます。Set には高速な has() 操作があります。
import { useState } from 'react';
import { letters } from './data.js';
import Letter from './Letter.js';
export default function MailClient() {
const [selectedIds, setSelectedIds] = useState(
new Set()
);
const selectedCount = selectedIds.size;
function handleToggle(toggledId) {
// Create a copy (to avoid mutation).
const nextIds = new Set(selectedIds);
if (nextIds.has(toggledId)) {
nextIds.delete(toggledId);
} else {
nextIds.add(toggledId);
}
setSelectedIds(nextIds);
}
return (
<>
<h2>Inbox</h2>
<ul>
{letters.map(letter => (
<Letter
key={letter.id}
letter={letter}
isSelected={
selectedIds.has(letter.id)
}
onToggle={handleToggle}
/>
))}
<hr />
<p>
<b>
You selected {selectedCount} letters
</b>
</p>
</ul>
</>
);
}export default function Letter({
letter,
onToggle,
isSelected,
}) {
return (
<li className={
isSelected ? 'selected' : ''
}>
<label>
<input
type="checkbox"
checked={isSelected}
onChange={() => {
onToggle(letter.id);
}}
/>
{letter.subject}
</label>
</li>
)
}export const letters = [{
id: 0,
subject: 'Ready for adventure?',
isStarred: true,
}, {
id: 1,
subject: 'Time to check in!',
isStarred: false,
}, {
id: 2,
subject: 'Festival Begins in Just SEVEN Days!',
isStarred: false,
}];input { margin: 5px; }
li { border-radius: 5px; }
label { width: 100%; padding: 5px; display: inline-block; }
.selected { background: #d2eaff; }これで、各アイテムは selectedIds.has(letter.id) というチェックを行うため、非常に高速になります。
state 内のオブジェクトはミューテーションしないように注意してください。これには Set も含まれます。そのため、handleToggle 関数ではまず Set のコピーを作成し、そのコピーを書き換えています。