If you have spent any time in React, you have seen the yellow warning in your console: “Each child in a list should have a unique ‘key’ prop.” It sounds like a polite suggestion, but React is actually warning you that it cannot tell your list items apart. Ignore it, and you will eventually ship a bug that is maddening to reproduce—state jumping to the wrong row, text inputs losing focus, or animations firing on the wrong element.
Reactのレンダリングエンジンは、UIをピクセル単位で比較しているわけではありません。仮想DOMと呼ばれる軽量なオブジェクトツリーを構築し、新しいツリーと以前のツリーを比較して、実際のDOMに必要な最小限の変更を計算します。リストをレンダリングするとき、Reactは兄弟要素の配列として認識します。keyがないと、アイテムが移動したのか、置き換えられたのか、あるいは削除されたのかを判断する確実な方法がありません。デフォルトでは位置に基づいて一致させようとしますが、これは脆弱です。keyは安定した識別子として機能します。「この要素は、たとえ別のスロットに移動したとしても、以前と同じものです」とReactに伝えるのです。これを間違えると、決定論的な更新が推測に基づいた動作に変わってしまいます。
The Minimum Fix
警告は通常、map呼び出しの中で発生します。イテレータから返される最上位の要素のkey属性に、一意の値を割り当てる必要があります。
以下は、警告を引き起こすあらゆるコードベースで見られるパターンです:
const UserList = ({ users }) => {
return (
<ul>
{users.map((user) => (
<li>{user.name}</li>
))}
</ul>
);
};
Reactは3つの<li>タグを見て、どれがどれなのか分かりません。修正は属性を1つ追加するだけです:
const UserList = ({ users }) => {
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
};
keyはmapのコールバック内で直接要素に割り当てなければなりません。もし<li>を別のUserItemコンポーネントとして抽出したとしても、keyは呼び出し側のコンポーネントに付与する必要があります:
{users.map((user) => (
<UserItem key={user.id} user={user} />
))}
UserItemの内部にある<div>にkeyを配置しても、警告は消えず、再照合(reconciliation)の挙動も修正されません。Reactは、イテレータによって返された要素上のkeyを探すからです。
Why Index as a Key Is Dangerous
mapの第2引数を使えば、手軽に警告を消せると感じるかもしれません:
{users.map((user, index) => (
<li key={index}>{user.name}</li>
))}
これでコンソールのノイズは消えますが、根本的な問題は解決していません。配列のインデックスは識別子ではありません。それらは「位置」であり、位置は変化するものです。
例えば、以下の順序でレンダリングされた3人のユーザーのリストを想像してください:
- Alice (index 0)
- Bob (index 1)
- Charlie (index 2)
Aliceを削除すると、Bobはインデックス0に、Charlieはインデックス1に移動します。Reactは新しいツリーと古いツリーを比較します。インデックス0にBobのデータが入っているのを見て、以前Aliceを表示していた既存のDOMノードを書き換えます。もしそのノードにフォーカスがあった場合、カーソルは最初の行に残ったまま、テキストだけがBobに変わります。もしその行にローカルな状態を持つ<input>があった場合、その状態はインデックス0に留まったままになります。ユーザーはBobの行に見える場所に文字を入力しますが、その状態はAliceのものでした。ソート、フィルタリング、または先頭への追加を行ったときにも、同じ混乱が起こります。インデックスをkeyとして安全に使えるのは、並べ替え、フィルタリング、挿入、削除が一切行われない、真に静的なリストだけです。決して変わることのないハードコードされたナビゲーションリンクなどは、その良い例です。それ以外はすべて、本物の識別子が必要です。
Where to Find a Stable Key
最善のkeyは、データモデルに既に存在する一意の識別子です。データベースのプライマリキー(idなど)は、一意であることが保証されており、レンダリングをまたいでも維持されるため理想的です。バックエンドからuuid、slug、あるいはその他の自然に一意なフィールドを持つオブジェクトが返される場合は、それを使用してください。
APIレスポンスに一意のフィールドがない場合、2つの現実的な方法があります。1つ目は、バックエンドチームに相談してidを含めてもらうことです。プライマリキーなしでリレーショナルデータを配信するのはスメル(設計上の不備)であり、ソース側で修正することでスタック全体の曖昧さを排除できます。2つ目は、クライアント側だけでアイテムを生成する場合(例えば、サーバーに送る前にタスクを作成するToDoリストなど)は、作成時に一度だけIDを生成することです。uuidやnanoidのようなライブラリは、まさにこのために作られています。ユーザーがフォームを送信したときにIDを生成し、オブジェクトに保存して、それを永続的にkeyとして使用してください。
レンダリングパスの中でkeyを生成してはいけません。コンポーネントのレンダリング中にMath.random()やDate.now()を呼び出すと、実行のたびに新しい値が生成されます。Reactは新しいkeyを見ると、それを全く新しい要素だと判断し、古いDOMノードを破棄して新しいものを作成します。その要素内のあらゆる状態がリセットされます。フォーカスは失われます。Reactが不必要なDOM操作を行うため、パフォーマンスも低下します。ランダムに生成されたkeyは、keyがない状態よりもさらに悪い結果を招きます。
Fragments, Components, and Scope
もう少し気づきにくい罠として、React Fragmentがあります。データを map でループ処理し、ラッパーとなる <div> を使わずに複数の兄弟要素を返したい場合、短縮構文を使いたくなるかもしれません。
{items.map((item) => (
<>
<dt>{item.term}</dt>
<dd>{item.definition}</dd>
</>
))}
短縮構文の <>...</> は props をサポートしていないため、key を付与することができません。その場合は、明示的なフル構文に切り替えてください。
{items.map((item) => (
<React.Fragment key={item.id}>
<dt>{item.term}</dt>
<dd>{item.definition}</dd>
</React.Fragment>
))}
React がレンダリングをまたいでそのペアを単一のユニットとして追跡できるように、Fragment にはその key が必要です。
もう一つの微妙な点として、key は通常の意味での props ではありません。<ListItem key={item.id} /> と記述した場合、ListItem コンポーネントは props.key を読み取ることができません。React は管理のために内部的にこれを使用します。もしコンポーネントのロジック自体に識別子が必要な場合は、itemId のように別の名前で別途渡してください。
安全に開発するための実践的なルール
- データベースの ID を優先する。 これらは一意であり、数値または文字列ベースで、かつ安定しています。
- クライアント側のみのデータには
uuidやnanoidを使用する。 ID はレコード作成時に一度だけ生成し、コンポーネントのレンダリング内では生成しないでください。 - リストが変化する可能性がある場合、配列のインデックスから key を決して生成しない。 ソート、フィルタリング、削除を行うと、表示上の不具合や状態(state)のバグを引き起こします。
Math.random()、Date.now()、またはレンダリング間で変化する値を決して使用しない。 これを行うと、不要なアンマウントと再マウントが強制されます。mapの中で使用し、keyが必要な場合は Fragment にフル構文が必要であることを忘れない。keyは子コンポーネントの中ではなく、mapによって返される要素に配置する。
本質的なまとめ
key プロップは、単なる見た目上の Lint ルールではありません。これは React がレンダリングをまたいでアイデンティティ(同一性)を維持するための仕組みです。データベースのテーブルにおける主キー(primary key)のように考えてください。そのアイデンティティが安定していれば、React は要素を正確に移動、更新、削除できます。アイデンティティが欠落していたり不安定だったりすると、UI 状態の破損や、再構築(reconciliation)の低速化という代償を払うことになります。データレイヤーで一度修正しておけば、リストがどれほど増大したり変化したりしても、予測可能な動作を維持できます。
