本文へスキップ
key の細かい話

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

22 / 64

key の細かい話

index を使っていい場合、key は props ではない、など

前の章で、key には id を使うという結論まで来ました。 ふだん書くぶんには、それで足ります。

この章はそのあとに必ず出てくる疑問を片づけます。 「index を使ってはいけないの?」「key って props じゃないの?」

読み流して、必要になったときに思い出せれば十分です。

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

分かりやすさのために入力欄を使いましたが、同じことは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" aria-label="メモを書いてみる" />
    </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 に Math.random() を使ったリストうまくいかない例

メモ欄に打ってから、ボタンを押す

まず各行のメモ欄に何か打ってから、ボタンを押してください。

さとう
すずき
たかはし

打った文字が消えました。リストの中身は 1 つも変えていません。 ただ描き直しただけです。 それでも key が毎回違うので、React は全部を別物とみなし、行ごと作り直しています

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

同じように、メモ欄に打ってからボタンを押す

こちらも、メモ欄に打ってからボタンを押してください。

さとう
すずき
たかはし

こちらは打った文字が残ります。 key が変わっていないので、React は同じ行だと判断し、 作り直さずにそのまま使います。

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 の影響は入力欄だけではない。state を持つ部品すべてに及ぶ
  • index を key にしてよいのは並び替えも追加も削除も起きないときだけ
  • key は props ではない。子には届かない。 必要なら id を別に渡す
  • 重複しなければよいのは兄弟の中だけ
  • Math.random()毎回別物になるので使わない

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>
  );
}

ここで Part 3 は終わりです

Part 3 では 3 つのことをやりました。押したら動く(イベント)、条件で出し分ける並べる(リストと key)。

この 3 つで、画面の見た目はだいたい作れます。 ですが、まだ決定的に足りないものがあります。

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>
  );
}