Reactのkey設計でリスト状態が入れ替わる実装をどうレビューするか

Reactのリスト描画では、key を指定する。
このルール自体はよく知られているが、レビューで本当に見たいのは「keyがあるか」ではない。

重要なのは、そのkeyがコンポーネントの同一性を正しく表しているかである。

特に危ないのは、次のような実装だ。

  • key={index} を使っている
  • 並び替えや削除があるリストでindex keyを使っている
  • 子コンポーネントが内部stateを持っている
  • 入力中のフォーム値や開閉状態が別行へ移る

この記事では、Reactのkeyを文法としてではなく、
リスト内の状態と同一性を守る設計要素としてレビューする観点を整理する。

まず止めたい実装

index keyで状態が入れ替わる実装
type Todo = {
  id: string;
  title: string;
};

function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map((todo, index) => (
        <TodoItem key={index} todo={todo} />
      ))}
    </ul>
  );
}

function TodoItem({ todo }: { todo: Todo }) {
  const [memo, setMemo] = useState("");

  return (
    <li>
      <span>{todo.title}</span>
      <input value={memo} onChange={event => setMemo(event.target.value)} />
    </li>
  );
}
Comment
@Reviewer: `key={index}` のため、並び替えや削除時にTodoItemの内部stateが別のtodoへ紐づく可能性があります。todoの同一性を表す `todo.id` をkeyにしてください。

このコードは、追加も削除も並び替えもしない静的リストなら問題が見えにくい。
しかし、1件削除した瞬間に、入力中の memo が別の行へ移ることがある。

レビューでは、警告を消すためのkeyではなく、
Reactがどの要素を同じコンポーネントと見なすべきかを確認する必要がある。

なぜindex keyが危ないのか

Reactは key を使って、前回のリストと次回のリストを対応付ける。
index をkeyにすると、配列上の位置が同じなら同じコンポーネントとして扱われる。

つまり、次のような変化に弱い。

  • 先頭の要素を削除する
  • ソート順を変える
  • 検索条件で途中の要素が消える
  • ドラッグアンドドロップで並び替える
  • 新しい要素を先頭に追加する

このとき、表示データは変わっても、子コンポーネントの内部stateは位置に残る。
レビューで見たいのは、このズレである。

レビューで見たい3つの判断線

1. リストが変化する可能性があるか

静的な注意書きリストのように、順序も要素も変わらないならindex keyでも実害は小さい。
しかし、業務画面の一覧はたいてい変化する。

Comment
@Reviewer: このリストは削除と並び替えが可能なため、indexをkeyにすると行の同一性が位置に依存します。データのIDをkeyにして、表示順の変化とコンポーネント状態を分離してください。

2. 子コンポーネントがstateを持っているか

子が内部stateを持つ場合、keyの誤りは目に見えるバグになる。

function UserRow({ user }: { user: User }) {
  const [expanded, setExpanded] = useState(false);
  return (
    <tr>
      <td>{user.name}</td>
      <td>
        <button onClick={() => setExpanded(v => !v)}>
          {expanded ? "close" : "open"}
        </button>
      </td>
    </tr>
  );
}
Comment
@Reviewer: `UserRow` が開閉状態を内部に持っているため、keyがindexだとソート時に開閉状態が別ユーザーへ移ります。user.idなど安定した識別子をkeyにしてください。

3. keyが画面上の一意性ではなく、データの同一性を表しているか

titlename をkeyにする実装も危険だ。
重複する可能性がある値は、同一性として弱い。

{users.map(user => (
  <UserRow key={user.name} user={user} />
))}
Comment
@Reviewer: `user.name` は表示名であり、重複や変更が起こり得ます。keyは表示上のラベルではなく、データの同一性を表す安定IDを使ってください。

改善例

リスト項目にIDがあるなら、それをkeyにする。

安定IDをkeyにする実装
function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

これで、並び順が変わっても todo.id が同じ項目は同じコンポーネントとして扱われる。

レビュー観点としては次が確認しやすい。

  • keyが配列上の位置に依存しない
  • 削除や並び替えで内部stateが別データへ移らない
  • 表示名変更でkeyが変わらない
  • データモデルの同一性がUIに反映されている

IDがない場合はどう見るか

外部APIや一時データでIDがないこともある。
その場合、レビューでは「indexでよいか」ではなく、安定IDをどの責務で作るかを確認する。

一時IDを作る例
type DraftItem = {
  clientId: string;
  label: string;
};

function createDraftItem(label: string): DraftItem {
  return {
    clientId: crypto.randomUUID(),
    label,
  };
}
Comment
@Reviewer: API由来のIDがないためindex keyになっていますが、このリストは追加・削除があります。画面内で安定するclientIdを生成し、項目の同一性を明示してください。

ただし、renderのたびに crypto.randomUUID() を呼んでkeyにするのは逆効果だ。
毎回keyが変わると、Reactは毎回別コンポーネントとして扱う。

レビュー観点チェックリスト

React key設計レビューの確認項目
  • key={index} が使われていないか
  • リストに追加、削除、ソート、フィルタ、並び替えがあるか
  • 子コンポーネントが内部stateを持っているか
  • keyが表示名やタイトルなど変更可能な値になっていないか
  • renderごとに新しいkeyを生成していないか
  • IDがない場合に、どの層で安定IDを作るか決まっているか

まとめ

Reactのkeyは、警告を消すための属性ではない。
リスト内のデータとコンポーネント状態を対応付ける、同一性の設計である。

レビューでは、keyがあるかではなく、
並び替えや削除が起きたときに状態が正しい行に残るかを確認したい。