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 にすれば一意になりますし、警告も消えます。 いちばん手軽な方法に見えます。
実際に何が起きるか、下のデモで確かめてください。
さとうに書いたはずのメモが、やまだの行に残ってしまう
打った文字は「さとう」ではなく、新しく入ってきた「やまだ」の行に付いたままになったはずです。 名前だけが入れ替わって、メモは動きませんでした。
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 番も、今回の 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 に何を渡すか、その 1 か所だけです。
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>
);
}