AIにコンポーネントを書かせると、map() の中に key={index} や key={item.id} をサラッと入れてきます。コンパイルは通るし警告も出ないことがあるので、一見問題なさそうに見えます。
でも実際に触ると「削除したはずの項目と違う項目が消える」「入力中の値が急に別の行に移る」といった不可解な挙動に出会うことがあります。今回はリストの key 指定でハマりやすい3パターンと対処法をまとめます。
key と reconciliation
Reactはstateが更新されるたびにDOMを丸ごと作り直すのではなく、前回と今回のレンダリング結果を比較して差分だけを反映します。この差分検出の仕組みを reconciliation(差分検出処理) と呼びます。
{items.map((item) => (
<li key={item.id}>{item.text}</li>
))}
配列を map() でレンダリングする場合、Reactは各要素の key を「前回のどの要素と同一とみなすか」の目印に使います。key が一致すれば同じ要素とみなしDOMノードやstate(useState の中身など)を使い回し、一致しなければ新規追加/削除と判断してDOMを作り直します。key の選び方を間違えると、この判定がズレて意図しない要素にstateが引き継がれたり、不要な作り直しが起きたりします。
罠1: keyにindexを使う
// Before: 罠あり
{todos.map((todo, index) => (
<li key={index}>
<input defaultValue={todo} />
</li>
))}
このリストが「並び替えなし・追加削除なし」の固定リストであれば実害はほとんど出ません。しかし先頭に要素を追加したり途中を削除したりすると問題が起きます。
例えば ["A", "B", "C"] の先頭に "D" を追加して ["D", "A", "B", "C"] になると、Reactから見ると「key=0 の中身が A から D に変わった」ように見えます。要素そのものが入れ替わったのではなく、同じkeyの要素に別のデータが流れ込んできた 扱いになります。
<input defaultValue={todo} /> はReactが「同じ要素」と判定するとDOMのinput要素を使い回します。defaultValue は初回マウント時にしか効かないため、ユーザーが A の欄に何か入力していても、その入力値を保持したまま中身だけ D の表示に切り替わる、といった stateの取り違え が起きます。
// After: item.idを使う
{todos.map((todo) => (
<li key={todo.id}>
<input defaultValue={todo.text} />
</li>
))}
item.id のようにデータ自体が持つ一意な値を key にすれば、並び替えや追加削除があっても「同じデータには同じkey」が保たれ、DOMとstateの対応関係が崩れません。
罠2: keyを付け忘れる
// Before: key未指定
{items.map((item) => (
<li>{item.text}</li>
))}
これを実行するとコンソールに次の警告が出ます。
Warning: Each child in a list should have a unique "key" prop.
動作自体は止まりませんが、Reactは内部的にindexをkey代わりに使うため、罠1と全く同じ問題を抱えたまま動いてしまうのが厄介なところです。警告さえ無視すれば一見動いているように見えるので、AIが生成したコードでこの警告をスルーしていないか確認しましょう。対処は罠1と同じで、一意なプロパティを key に指定するだけです。
罠3: keyに毎回変わる値を使う
逆に「毎回ユニークにすれば安全だろう」と Math.random() を key に使ってしまうケースもあります。
// Before: 罠あり(毎レンダーでkeyが変わる)
{items.map((item) => (
<li key={Math.random()}>
<input defaultValue={item.text} />
</li>
))}
一見ユニークで安全に思えますが、実際は最も破壊的な罠です。Math.random() はレンダーのたびに新しい値を返すため、前回のkeyと今回のkeyが絶対に一致しません。Reactは「前回のkeyと一致するものが1つもない」=「全要素が消えて全く新しい要素が追加された」と判定し、既存のDOMノードをすべて破棄して作り直します。
具体的には、<input> の入力途中の値がレンダーのたびに消える(defaultValue の初期値に戻る)、CSSアニメーション(マウント時トランジションなど)が毎回最初から再生される、リストが大きいほどパフォーマンスが悪化する、といった不具合が起きます。
// After: item.idを使う(レンダーを跨いで安定)
{items.map((item) => (
<li key={item.id}>
<input defaultValue={item.text} />
</li>
))}
key はレンダーを跨いで安定した値でなければ意味がありません。乱数・タイムスタンプのようにレンダーのたびに変わる値は key に向いていません。
対処まとめ
基本方針はシンプルで、サーバー/データ側の一意なID(item.id など)をkeyに使うことです。IDが存在せず、かつリストが「追加・削除・並び替えが一切発生しない」と確信できる場合に限り、indexも許容範囲になります。ただし判断に迷う・将来リストが動的になる可能性があるなら、最初から一意なプロパティを使うほうが安全です。
AIレビュー観点+実プロンプト
AIが生成したコードで map() を見つけたら、indexがkeyに使われていないか、並び替え・追加削除が将来発生し得ないか、keyの値がレンダーごとに変わらないか、を確認しています。実際にAIレビューを依頼する時のプロンプトはこちらです。
このコンポーネント内のmap()でレンダリングしているリストについて、
keyの指定方法をレビューしてください。
- indexをkeyに使っている箇所がないか
- レンダーごとに値が変わるものをkeyに使っていないか
- 将来並び替え/追加/削除される可能性がある場合、耐えられるkey設計か
問題があれば、item.idなど一意なプロパティを使う修正案を出してください。
「動く」と「壊れない」の間には結構な距離があるので、AIが書いたコードでも key 周りは一度自分の目で確認しておくのがおすすめです。
中上級者には当たり前すぎる内容なので初心者の知り合いへの紹介や社内研修の参考にどうぞ。
未経験から Next.js + Supabase + Claude Code で Web アプリを公開するまで
- 無料体験: next-ai-camp-trial (GitHub)
- 教材+メンタリング: MENTAプラン
- YouTube『AIエンジニア情報局』(MENTAとは別運営のチャンネルです)でも発信しています