リストと key
配列をコンポーネントにマッピングするパターンと key の意味、そしてインデックスを key として使うと壊れる実際の例を見ていきます。
7 章で画面を条件に応じて違うように描くパターンを扱いました。本章ではもう 1 つの必須トピックである複数のデータを一度に描く方法と、そこに必ず登場する特別な prop である key を見ていきます。
key は単なる命名規則ではなく、14 章(パフォーマンス最適化)の reconciliation アルゴリズムに直結する道具です。本章で key の基本原理をしっかり押さえておけば、14 章が軽く読めます。
配列を画面に描く方法 #
画面に描くデータが配列なら、map メソッドで各項目を JSX に変換してそのまま JSX の中に入れます。
function FruitList() {
const fruits = ['りんご', 'バナナ', 'チェリー'];
return (
<ul>
{fruits.map(fruit => <li key={fruit}>{fruit}</li>)}
</ul>
);
}
export default FruitList;ポイントは 2 つです。
fruits.map(...)が JSX 要素の配列を作る- React は JSX の中に JSX の配列が入ってくると、その要素を順番にレンダリングする
配列をそのまま JSX に入れて構わないということです。ただしそこには 1 つの約束があり、各要素ごとに key という prop を与える必要があるということです。
key はなぜ必要なのか #
key は React が各項目を識別するための一意な ID の役割を果たします。リストが変わるとき(追加 / 削除 / 並べ替え)、React が何がどう変わったかを効率的に把握するには、各項目を区別できる必要があります。
key がないと React は毎回すべての要素を最初から描き直すべきか、既存のものを再利用すべきかを正確に判断しにくくなります。結果としてパフォーマンスが落ちたり、ある場合には画面がおかしくちらついたり、入力欄のフォーカスが思わぬ場所へ移ったりする微妙なバグが生じたりします。
key を忘れると React はコンソールに警告を出します。
Warning: Each child in a list should have a unique "key" prop.よい key とは #
よい key は次の条件を満たします。
- 一意である — 兄弟項目の間で重ならないこと。世界中で一意である必要はなく、同じリストの中で一意であれば十分です。
- 安定している — 同じ項目であれば再レンダリングが起きても同じ key を持つこと
最も自然な候補は、データが持っている一意な ID です。データベースの PK、サーバから受け取った id フィールドのようなものです。
function PostList({ posts }) {
return (
<ul>
{posts.map(post => (
<li key={post.id}>
{post.title}
</li>
))}
</ul>
);
}ID のない単純なデータ(文字列の配列のような)であれば、値が一意である保証があれば値そのものを key として使えます。
{fruits.map(fruit => <li key={fruit}>{fruit}</li>)}ただし「りんご」が 2 回入っている配列であれば、同じ key が 2 つになって警告が出ます。そのような危険があれば、ID を付けてオブジェクトとして扱う方が安全です。
インデックスを key として使えないのですか #
map の 2 番目の引数としてインデックスを受け取れるので、「単にインデックスを使えばよいのでは?」と思うかもしれません。
{fruits.map((fruit, index) => <li key={index}>{fruit}</li>)}これは動作はしますが、React 公式ドキュメントが明示的に推奨しない方式です。リストの順序が変わったり、途中に項目が追加 / 削除されたりする可能性があるとバグを引き起こすからです。
インデックス key が壊れる例 #
次の状況を想像してみてください。
function TodoList() {
const [todos, setTodos] = useState([
{ text: 'React の勉強' },
{ text: '運動する' },
{ text: '本を読む' },
]);
return (
<ul>
{todos.map((todo, index) => (
<li key={index}>
{todo.text} <input type="text" placeholder="メモ" />
</li>
))}
</ul>
);
}各項目の横にメモ入力欄があり、ユーザが「運動する」の横に「午後 7 時」と入力したとしましょう。その状態で先頭に新しいタスクが追加されるとどうなるでしょうか。
- インデックス 0 だった「React の勉強」は今インデックス 1
- インデックス 1 だった「運動する」は今インデックス 2
- 新しく入ってきた項目がインデックス 0
React は key を見て「あ、0 番の項目はそのままあるな」と判断します。しかし実際のデータは別の項目に変わっています。その結果、ユーザが「運動する」の横に入力した「午後 7 時」が見当違いの項目の横にそのまま残るという、おかしなことが起きます。
解決策は各項目に本当の一意な ID を付けることです。
function TodoList() {
const [todos, setTodos] = useState([
{ id: 'a1', text: 'React の勉強' },
{ id: 'a2', text: '運動する' },
{ id: 'a3', text: '本を読む' },
]);
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.text} <input type="text" placeholder="メモ" />
</li>
))}
</ul>
);
}これで項目が追加されたり並べ替えられたりしても、各項目の入力欄の内容が正確に追いついてきます。
インデックス key が安全な場合 #
リストが静的で(項目の追加 / 削除 / 並べ替えがなし)単純な表示用のときは、インデックス key を使っても大きな問題はありません。それでも一意な ID があるならそれを使う習慣を付けるのがよいです。最初は静的だったリストが後で動的になることがよくあるからです。
crypto.randomUUID() を呼び出すと一意な ID 文字列を作れます。または単に増えていく数値を使っても構いません(Date.now() など)。コンポーネントに分けて描く #
<li> の中身が長くなったら別のコンポーネントに分けるのが普通です。このとき key は map が生み出す最上位要素に付ける必要がある、という点を覚えておいてください。
function TodoItem({ todo }) {
return (
<li>
<strong>{todo.text}</strong>. {todo.completed ? '完了' : '進行中'}
</li>
);
}
export default TodoItem;import TodoItem from './TodoItem';
function TodoList({ todos }) {
return (
<ul>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
export default TodoList;key を TodoItem の中の <li> に付けず、map が返す <TodoItem> そのものに付ける必要があります。子コンポーネントの内側のどこかに付けるのではなく、リストを作っているその位置(map のコールバックが返す要素)に付けるのです。
filter と組み合わせる #
JavaScript の配列メソッドは自由に組み合わせられます。完了していないタスクだけを見せるには filter で絞ってから map をつなげます。
function TodoList({ todos }) {
return (
<ul>
{todos
.filter(todo => !todo.completed)
.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}並べ替えも同じく sort(またはより安全には [...todos].sort(...))と一緒に使えます。
sort は元の配列を直接書き換えます。props として受け取った配列を直接書き換えるのは 4 章で学んだ「props は読み取り専用」の原則に反し、state の配列に対しても 5 章で学んだ「直接変更禁止」の原則に反します。並べ替えが必要なら常に [...todos].sort(...) のようにコピーを作って並べ替えてください。空の配列を扱う #
データが空のときに「空です」というメッセージを見せるには、7 章で学んだ条件付きレンダリングと組み合わせます。
function TodoList({ todos }) {
if (todos.length === 0) {
return <p>タスクがありません。</p>;
}
return (
<ul>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}空の <ul> は意味的に不自然なので、early return で処理する方が自然です。
自分でやってみる #
6 章で作った MessageForm を本物のメッセージリストへ発展させます。本章までに学んだものをすべて使います。
src/MessageForm.jsx を次のように変えます。
import { useState } from 'react';
function MessageForm() {
const [name, setName] = useState('');
const [message, setMessage] = useState('');
const [messages, setMessages] = useState([]);
const isValid = name.length > 0 && message.length > 0;
function handleSubmit(e) {
e.preventDefault();
if (!isValid) return;
const newMessage = {
id: crypto.randomUUID(),
name,
message,
createdAt: new Date().toLocaleTimeString(),
};
setMessages(prev => [newMessage, ...prev]);
setName('');
setMessage('');
}
return (
<div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="名前"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
type="text"
placeholder="メッセージ"
value={message}
onChange={(e) => setMessage(e.target.value)}
style={{ marginLeft: '8px' }}
/>
<button type="submit" disabled={!isValid} style={{ marginLeft: '8px' }}>
追加
</button>
</form>
<div style={{ marginTop: '16px' }}>
{messages.length === 0 ? (
<p style={{ color: '#888' }}>まだメッセージがありません。</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{messages.map(item => (
<li
key={item.id}
style={{ borderBottom: '1px solid #eee', padding: '8px 0' }}
>
<strong>{item.name}</strong>
<span style={{ color: '#888', marginLeft: '8px', fontSize: '12px' }}>
{item.createdAt}
</span>
<p style={{ margin: '4px 0 0 0' }}>{item.message}</p>
</li>
))}
</ul>
)}
</div>
</div>
);
}
export default MessageForm;複数のメッセージを追加してみてください。各メッセージが上から積まれ、crypto.randomUUID() で作った一意な ID が key として使われます。空の配列のときは案内文が出て、メッセージがあればリストが描かれます。
ここまで学んだものすべてが 1 つの画面に混ざっています。props(子要素にデータを渡す)、state(useState)、イベントハンドリング(onSubmit、onChange)、条件付きレンダリング(messages.length === 0 ? ... : ...)、そして本章で学んだリストレンダリング(map + key)。短いコードですが、React の核心をほぼすべて見せる例です。
練習問題 #
- 上の
MessageFormの各メッセージ項目の横に「削除」ボタンを追加してみてください。クリック時にそのメッセージがリストから消えるようにします。setMessages(prev => prev.filter(m => m.id !== item.id))パターン。 - インデックス key の罠を直接体験してみる。
MessageFormのkey={item.id}をしばらくkey={index}(map コールバックの 2 番目の引数)に変えてから、各項目に別途のメモ入力欄<input type="text" placeholder="メモ" />を追加してみてください。メモを 2、3 行入力した状態で新しいメッセージを追加すると、メモが見当違いの項目に追いついてくる現象が見えます。その後key={item.id}に戻して現象が消えるのを確認します。 filter+mapの組み合わせ。MessageFormに検索ボックスを 1 つ追加し、入力した単語がメッセージ本文に含まれる項目だけを表示するようにしてみてください。messages.filter(m => m.message.includes(search)).map(...)パターン。検索ボックス自体も controlled 入力(9 章で扱います)です。
一行まとめ: 配列は
mapで JSX の配列を作って JSX の中に入れる。各要素には一意で安定したkeyが必要だ。可能ならデータの ID を使い、インデックス key はアンチパターンだ。keyはmapコールバックが返す最上位要素に付ける。filter・並べ替え・条件付きレンダリングと自由に組み合わせられる。
次の章 #
この章までが 1 部の 1 次まとめです。カウンタ、トグル、メッセージフォームのような小さなインタラクティブコンポーネントは無理なく作れるようになっていることでしょう。1 部の最終章である次の第 9 章 フォームの扱いでは、ほぼすべてのアプリに登場するフォームを扱う定石パターン — controlled component — を本格的に見ていきます。19 章(イベントとフォームの型付け)と 27 章(Server Actions)の土台になります。