リストと key

Part 3 · 画面に動きをつける

リストと key

key を間違えると、入力した文字が別の行に移動する

配列をそのまま画面に並べる、というのは React でいちばんよく書く処理です。商品の一覧、コメントの一覧、検索結果。 扱うデータが変わるだけで、やっていることは毎回同じです。

そしてこれを書くと、React は必ず「key を付けてください」と言ってきます。 とりあえず index を入れると警告は消えます。多くの人がそうしています。

ですがそれをやると、いつか奇妙なバグに出会います。入力欄に打った文字が、別の行に移動するのです。 この章では、それを実際に壊しながら、なぜ起きるのかを見ていきます。

まず、配列を画面に並べる

配列を画面に出すときは map を使います。map は「配列の要素をひとつずつ別のものに作り変えて、新しい配列を返す」関数でした。 React では、この「別のもの」を JSX にします。

JSX の配列を { } の中に置くと、React はそれを順番に並べて表示します。3 つの要素が入った配列を渡せば、3 行出ます。for 文を書く必要はありません。

配列を並べただけのリスト
  • さとう
  • すずき
  • たかはし

右のコードを見てください。li key={member.id} という見慣れない属性が付いています。 これがこの章の主役です。

basic-list.tsx

"use client";

const members = [
  { id: 1, name: "さとう" },
  { id: 2, name: "すずき" },
  { id: 3, name: "たかはし" },
];

export function BasicList() {
  return (
    <ul className="flex flex-col gap-2.5">
      {members.map((member) => (
        <li key={member.id} className="rounded border p-2">
          {member.name}
        </li>
      ))}
    </ul>
  );
}

key を書かないと警告が出る

この key を消すと、ブラウザのコンソールに警告が出ます。

Warning: Each child in a list should have a unique "key" prop.

これは書き方の作法の問題ではありません。key がないと React は正しく動けません。なぜそうなのかを理解するには、React が画面を更新するときに何をしているかを知る必要があります。

React は変わったところだけを描き直す

state が変わると、React はコンポーネントをもう一度実行して、新しい JSX を作ります。このとき React は画面を全部作り直すことはしません。 前回の結果と今回の結果を見比べて、変わったところだけを実際の画面に反映します。

全部作り直してしまうと、入力中の文字が消えたり、スクロール位置が飛んだりして、 とても使えたものではないからです。

ここで、リストが問題になります。3 行だったリストが 4 行になったとき、React はどう解釈すればいいでしょうか。

  • 先頭に 1 行挿入されて、もとの 3 行がひとつずつ下にずれた
  • もとの 3 行の中身が全部書き換わって、末尾に 1 行増えた

見えている結果はどちらも同じです。けれども、どちらと解釈するかで、画面に対して行う操作はまったく変わります。 そして React には、これを自力で判断する材料がありません。

basic-list.tsx

"use client";

const members = [
  { id: 1, name: "さとう" },
  { id: 2, name: "すずき" },
  { id: 3, name: "たかはし" },
];

export function BasicList() {
  return (
    <ul className="flex flex-col gap-2.5">
      {members.map((member) => (
        <li key={member.id} className="rounded border p-2">
          {member.name}
        </li>
      ))}
    </ul>
  );
}

index を key にすると壊れる

配列には最初から通し番号があります。map の 2 番目の引数で受け取れる index です。これを key にすれば一意になりますし、警告も消えます。 いちばん手軽な方法に見えます。

実際に何が起きるか、下のデモで確かめてください。

key に index を使ったリストうまくいかない例

さとうに書いたはずのメモが、やまだの行に残ってしまう

さとう
すずき
たかはし

打った文字は「さとう」ではなく、新しく入ってきた「やまだ」の行に付いたままになったはずです。 名前だけが入れ替わって、メモは動きませんでした。

broken-list.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";
import { MemberRow } from "./member-row";

type Member = { id: number; name: string };

const initialMembers: Member[] = [
  { id: 1, name: "さとう" },
  { id: 2, name: "すずき" },
  { id: 3, name: "たかはし" },
];

const newcomers = ["やまだ", "いとう", "わたなべ", "こばやし"];

export function BrokenList() {
  const [members, setMembers] = useState(initialMembers);

  const addToTop = () => {
    const name = newcomers[members.length - 3];
    if (!name) return;

    // 先頭に追加する。既存のメンバーは 1 つずつ後ろにずれる
    setMembers([{ id: members.length + 1, name }, ...members]);
  };

  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-col gap-2.5">
        {members.map((member, index) => (
          // key に index を使っている。ここが問題
          <MemberRow key={index} name={member.name} />
        ))}
      </div>

      <div className="flex gap-2">
        <Button size="sm" onClick={addToTop}>
          先頭にメンバーを追加
        </Button>
        <Button
          size="sm"
          variant="outline"
          onClick={() => setMembers(initialMembers)}
        >
          最初から
        </Button>
      </div>
    </div>
  );
}

なぜそうなるのか

index を key にするということは、key の意味が「上から何番目か」になるということです。 データが持っている情報ではなく、並び順そのものです。

先頭に 1 人追加すると、さとうは 0 番目から 1 番目に移動します。 ところが React から見えているのは、こうです。

追加する前追加したあと0さとう1すずき2たかはし0やまだ箱を流用1さとう2すずき3たかはし新しく作る
key が index だと、React は「0 番の行の名前が さとう から やまだ に変わった」と解釈する

前回の 0 番も、今回の 0 番も、key はどちらも 0 です。 React はこれを同じ行だと判断します。 そして「0 番の行は、名前が さとう から やまだ に変わっただけだ」と解釈し、 さとうの行として使っていた画面上の要素をそのまま流用して、名前の部分だけを書き換えます。

broken-list.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";
import { MemberRow } from "./member-row";

type Member = { id: number; name: string };

const initialMembers: Member[] = [
  { id: 1, name: "さとう" },
  { id: 2, name: "すずき" },
  { id: 3, name: "たかはし" },
];

const newcomers = ["やまだ", "いとう", "わたなべ", "こばやし"];

export function BrokenList() {
  const [members, setMembers] = useState(initialMembers);

  const addToTop = () => {
    const name = newcomers[members.length - 3];
    if (!name) return;

    // 先頭に追加する。既存のメンバーは 1 つずつ後ろにずれる
    setMembers([{ id: members.length + 1, name }, ...members]);
  };

  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-col gap-2.5">
        {members.map((member, index) => (
          // key に index を使っている。ここが問題
          <MemberRow key={index} name={member.name} />
        ))}
      </div>

      <div className="flex gap-2">
        <Button size="sm" onClick={addToTop}>
          先頭にメンバーを追加
        </Button>
        <Button
          size="sm"
          variant="outline"
          onClick={() => setMembers(initialMembers)}
        >
          最初から
        </Button>
      </div>
    </div>
  );
}

流用された箱の中身は残る

ここで効いてくるのが、メモ欄です。

ここに打った文字は、React が管理している値ではありません。 ブラウザの入力欄そのものが内部で持っている値です。 React は名前の部分しか書き換えないので、文字はその場に残ります

結果として、さとうに書いたメモが、やまだの行に付いているように見える。 これがあのバグの正体です。

member-row.tsx

"use client";

import { Input } from "@/components/ui/input";

/**
 * リストの 1 行。
 *
 * メモ欄に打った値は React の state ではなく、ブラウザの入力欄そのものが持っている。
 * この「React が管理していない値」が、key を間違えたときに置き去りになる。
 */
export function MemberRow({ name }: { name: string }) {
  return (
    <div className="flex items-center gap-3 rounded-md border px-3 py-2">
      <span className="w-16 shrink-0 font-medium">{name}</span>
      <Input placeholder="メモを書いてみる" className="h-8" />
    </div>
  );
}

直し方

直すのは簡単です。そのデータ自身が持っている、変わらない値を key にします。今回のデータには id があるので、それを使います。

key に id を使ったリスト直した例

さっきと同じ手順で試してみてください

さとう
すずき
たかはし

今度はメモがさとうの行に付いてきました。 コードの違いは key に何を渡すか、その 1 か所だけです。

追加する前追加したあと1さとう2すずき3たかはし4やまだ新しく作る1さとう移動しただけ2すずき3たかはし
key が id だと、さとう・すずき・たかはし は「下にずれただけ」と正しく判断される

key が member.id なら、さとうはいつでも key が 1 です。先頭に入ってきたやまだは 4、 これまでに登場したことのない値です。

React は「key が 1 の行は前回もあった。位置が変わっただけだ」と判断して、 その行を丸ごと下に移動させます。 メモ欄もさとうの行と一緒に移動するので、打った文字も付いてきます。 key が 4 の行は初めて見るので、新しく作られます。

fixed-list.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";
import { MemberRow } from "./member-row";

type Member = { id: number; name: string };

const initialMembers: Member[] = [
  { id: 1, name: "さとう" },
  { id: 2, name: "すずき" },
  { id: 3, name: "たかはし" },
];

const newcomers = ["やまだ", "いとう", "わたなべ", "こばやし"];

export function FixedList() {
  const [members, setMembers] = useState(initialMembers);

  const addToTop = () => {
    const name = newcomers[members.length - 3];
    if (!name) return;

    setMembers([{ id: members.length + 1, name }, ...members]);
  };

  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-col gap-2.5">
        {members.map((member) => (
          // データ自身が持つ id を key にする
          <MemberRow key={member.id} name={member.name} />
        ))}
      </div>

      <div className="flex gap-2">
        <Button size="sm" onClick={addToTop}>
          先頭にメンバーを追加
        </Button>
        <Button
          size="sm"
          variant="outline"
          onClick={() => setMembers(initialMembers)}
        >
          最初から
        </Button>
      </div>
    </div>
  );
}

これは入力欄だけの話ではない

分かりやすさのために入力欄を使いましたが、同じことはstate を持つコンポーネントでも起きます。

  • チェックを入れたはずの行と、別の行にチェックが付く
  • 開いていたアコーディオンが、別の項目で開いたままになる
  • アニメーションが途中から始まる、あるいは動かない

React が「同じ行だ」と判断した箱の中身は、そのまま次のデータに引き継がれます。 key を間違えると、これが全部ズレます。

member-row.tsx

"use client";

import { Input } from "@/components/ui/input";

/**
 * リストの 1 行。
 *
 * メモ欄に打った値は React の state ではなく、ブラウザの入力欄そのものが持っている。
 * この「React が管理していない値」が、key を間違えたときに置き去りになる。
 */
export function MemberRow({ name }: { name: string }) {
  return (
    <div className="flex items-center gap-3 rounded-md border px-3 py-2">
      <span className="w-16 shrink-0 font-medium">{name}</span>
      <Input placeholder="メモを書いてみる" className="h-8" />
    </div>
  );
}

index を key にしていい場合

いつでもダメというわけではありません。 次の条件を全部満たすなら、index で問題ありません。

  • 並び替えが起きない
  • 途中への追加や削除が起きない(末尾に足すだけなら大丈夫)
  • 各行が入力欄や state を持っていない

たとえば、固定のメニュー項目を並べるだけのリストなら index で十分です。そもそも順番が変わらないので、index は事実上そのデータ固有の値になっています。

broken-list.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";
import { MemberRow } from "./member-row";

type Member = { id: number; name: string };

const initialMembers: Member[] = [
  { id: 1, name: "さとう" },
  { id: 2, name: "すずき" },
  { id: 3, name: "たかはし" },
];

const newcomers = ["やまだ", "いとう", "わたなべ", "こばやし"];

export function BrokenList() {
  const [members, setMembers] = useState(initialMembers);

  const addToTop = () => {
    const name = newcomers[members.length - 3];
    if (!name) return;

    // 先頭に追加する。既存のメンバーは 1 つずつ後ろにずれる
    setMembers([{ id: members.length + 1, name }, ...members]);
  };

  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-col gap-2.5">
        {members.map((member, index) => (
          // key に index を使っている。ここが問題
          <MemberRow key={index} name={member.name} />
        ))}
      </div>

      <div className="flex gap-2">
        <Button size="sm" onClick={addToTop}>
          先頭にメンバーを追加
        </Button>
        <Button
          size="sm"
          variant="outline"
          onClick={() => setMembers(initialMembers)}
        >
          最初から
        </Button>
      </div>
    </div>
  );
}

よくある勘違い

key は props ではない

key は React が内部で使うための特別な属性で、子コンポーネントには渡りません。 子で受け取ろうとしても、そこには何も入っていません。 行の中で id が必要なら、別の名前でもう一度渡します。

// id を子でも使いたいときは、key とは別に渡す
<MemberRow key={member.id} id={member.id} name={member.name} />

一意でなければいけないのは「兄弟の中だけ」

ページ全体で一意である必要はありません。 同じ map から出てきた要素どうしで重複していなければ大丈夫です。 画面上の別々のリストで key が 1 どうしぶつかっていても、 何の問題もありません。

key に Math.random() を使ってはいけない

一意な値がほしくて Math.random() Date.now() を使いたくなることがありますが、これは最悪の選択です。

// 絶対にやってはいけない
{members.map((member) => (
  <MemberRow key={Math.random()} name={member.name} />
))}

描き直されるたびに違う値になるので、React は毎回「全部が知らない行だ」と判断します。 つまり毎回すべての行が作り直されます。 遅くなるうえに、入力中の文字も state も毎回消えます。

key は同じデータなら毎回同じ値でなければ、 名札としての意味がありません。

fixed-list.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";
import { MemberRow } from "./member-row";

type Member = { id: number; name: string };

const initialMembers: Member[] = [
  { id: 1, name: "さとう" },
  { id: 2, name: "すずき" },
  { id: 3, name: "たかはし" },
];

const newcomers = ["やまだ", "いとう", "わたなべ", "こばやし"];

export function FixedList() {
  const [members, setMembers] = useState(initialMembers);

  const addToTop = () => {
    const name = newcomers[members.length - 3];
    if (!name) return;

    setMembers([{ id: members.length + 1, name }, ...members]);
  };

  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-col gap-2.5">
        {members.map((member) => (
          // データ自身が持つ id を key にする
          <MemberRow key={member.id} name={member.name} />
        ))}
      </div>

      <div className="flex gap-2">
        <Button size="sm" onClick={addToTop}>
          先頭にメンバーを追加
        </Button>
        <Button
          size="sm"
          variant="outline"
          onClick={() => setMembers(initialMembers)}
        >
          最初から
        </Button>
      </div>
    </div>
  );
}

理解できたか確かめる

手を動かす必要はありません。選んで、解説を読んでください。

確認クイズ

index を key にしても問題が起きないのは、次のうちどれ?

確認クイズ

key に Math.random() を使うと何が起きる?

確認クイズ

子コンポーネントの中で、その行の id を使いたい。どうする?

fixed-list.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";
import { MemberRow } from "./member-row";

type Member = { id: number; name: string };

const initialMembers: Member[] = [
  { id: 1, name: "さとう" },
  { id: 2, name: "すずき" },
  { id: 3, name: "たかはし" },
];

const newcomers = ["やまだ", "いとう", "わたなべ", "こばやし"];

export function FixedList() {
  const [members, setMembers] = useState(initialMembers);

  const addToTop = () => {
    const name = newcomers[members.length - 3];
    if (!name) return;

    setMembers([{ id: members.length + 1, name }, ...members]);
  };

  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-col gap-2.5">
        {members.map((member) => (
          // データ自身が持つ id を key にする
          <MemberRow key={member.id} name={member.name} />
        ))}
      </div>

      <div className="flex gap-2">
        <Button size="sm" onClick={addToTop}>
          先頭にメンバーを追加
        </Button>
        <Button
          size="sm"
          variant="outline"
          onClick={() => setMembers(initialMembers)}
        >
          最初から
        </Button>
      </div>
    </div>
  );
}

この章のまとめ

  • key は React に「どの行がどのデータのものか」を伝える名札
  • React は key を見て、画面上の要素を再利用するか作り直すかを決める
  • index は「並び順」であってデータの情報ではないので、順番が変わると壊れる
  • 各行が入力欄や state を持っているときに、その壊れ方がはっきり出る
  • 迷ったら、そのデータ自身が持つ id を使う

fixed-list.tsx

"use client";

import { Button } from "@/components/ui/button";
import { useState } from "react";
import { MemberRow } from "./member-row";

type Member = { id: number; name: string };

const initialMembers: Member[] = [
  { id: 1, name: "さとう" },
  { id: 2, name: "すずき" },
  { id: 3, name: "たかはし" },
];

const newcomers = ["やまだ", "いとう", "わたなべ", "こばやし"];

export function FixedList() {
  const [members, setMembers] = useState(initialMembers);

  const addToTop = () => {
    const name = newcomers[members.length - 3];
    if (!name) return;

    setMembers([{ id: members.length + 1, name }, ...members]);
  };

  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-col gap-2.5">
        {members.map((member) => (
          // データ自身が持つ id を key にする
          <MemberRow key={member.id} name={member.name} />
        ))}
      </div>

      <div className="flex gap-2">
        <Button size="sm" onClick={addToTop}>
          先頭にメンバーを追加
        </Button>
        <Button
          size="sm"
          variant="outline"
          onClick={() => setMembers(initialMembers)}
        >
          最初から
        </Button>
      </div>
    </div>
  );
}