React基礎講座 #8 リストとkey

読了 8分

前回は画面を条件に応じて違って描くパターンを扱いました。今回はもう1つの必須トピックである複数のデータを一度に描く方法と、そこで欠かさず登場する特別なpropであるkeyについて見ていきます。

配列を画面に描く方法 #

画面に描くデータが配列なら、mapメソッドで各項目をJSXに変換してそのままJSXの中に入れます。

src/FruitList.jsx
function FruitList() {
  const fruits = ['りんご', 'バナナ', 'チェリー'];

  return (
    <ul>
      {fruits.map(fruit => <li key={fruit}>{fruit}</li>)}
    </ul>
  );
}

export default FruitList;

ポイントは2つです。

  1. fruits.map(...)がJSX要素の配列を作る
  2. ReactはJSXの中にJSXの配列が入ると、その要素を順番にレンダリングする

配列をそのままJSXに入れてもいいんです。ただしそこで1つの約束があり、各要素ごとにkeyというpropを与える必要があります。

keyはなぜ必要なのか? #

keyはReactが各項目を識別するための一意のIDの役割をします。リストが変わるとき(追加/削除/順序変更)Reactが何がどう変わったかを効率的に把握するには、各項目を区別できる必要があります。

keyがないとReactは毎回すべての要素を最初から描き直すか、それとも既存のものを再利用するかを正確に判断するのが難しくなります。結果としてパフォーマンスが落ちたり、ある場合には画面が変にちらついたり、入力フィールドのフォーカスが見当違いのところに移ったりと、微妙なバグが発生することもあります。

keyを抜かすとReactはコンソールに警告を出します。

コンソールの警告
Warning: Each child in a list should have a unique "key" prop.

よいkeyとは #

よいkeyは次の条件を満たします。

  • 一意である — 兄弟項目同士で重複しないこと(全世界で唯一である必要はなく、同じリストの中だけで唯一であればよい)
  • 安定している — 同じ項目ならレンダリングが再度起きても同じkeyを持つこと

もっとも自然な候補はデータが持っている一意のIDです。データベースのPK、サーバーから受け取ったidフィールドのようなものですね。

src/PostList.jsx
function PostList({ posts }) {
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          {post.title}
        </li>
      ))}
    </ul>
  );
}

IDがない単純なデータ(文字列の配列のような)なら、値が一意であることが保証されているなら値そのものをkeyとして使ってもよいです。

値が一意の場合
{fruits.map(fruit => <li key={fruit}>{fruit}</li>)}

ただし、「りんご」が2回入っている配列だと同じkeyが2つになるので警告が出ます。そういう危険があるならIDを付与してオブジェクトとして扱う方が安全です。

インデックスをkeyとして使ってはダメですか? #

mapの2番目の引数でインデックスを受け取れるので、「ただインデックスを使えばいいのでは?」と思うかもしれません。

アンチパターン
{fruits.map((fruit, index) => <li key={index}>{fruit}</li>)}

これは動作はしますが、React公式ドキュメントが明示的に推奨しない方式です。リストの順序が変わったり、途中で項目が追加/削除される可能性があるならバグを誘発するからです。

インデックスkeyが壊れる例 #

次の状況を想像してください。

間違った例 — インデックスkey + 入力フィールド
function TodoList() {
  const [todos, setTodos] = useState([
    { text: 'Reactの勉強' },
    { text: '運動する' },
    { text: '本を読む' },
  ]);

  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={index}>
          {todo.text} <input type="text" placeholder="メモ" />
        </li>
      ))}
    </ul>
  );
}

各項目の横にメモ入力フィールドがあり、ユーザーが「運動する」の横に「午後7時」と入力したとしましょう。その状態で先頭に新しいタスクが追加されるとどうなるでしょうか?

  • インデックス0だった「Reactの勉強」は今インデックス1
  • インデックス1だった「運動する」は今インデックス2
  • 新しく入ってきた項目がインデックス0

Reactはkeyを見て「あ、0番の項目はそのままだな」と判断します。しかし実際のデータは別の項目に変わっています。その結果、ユーザーが「運動する」の横に入力した「午後7時」が見当違いの項目の横にそのまま残っているという変なことが起きます。

インデックスkeyが安全な場合 #

リストが静的で(項目の追加/削除/順序変更なし)単純表示用のときはインデックスkeyを使っても大きな問題はありません。しかしそういう場合でも一意のIDがあるならそれを使う習慣を身につける方がよいです。最初は静的だったリストが後で動的になるケースがよくあるからです。

ヒント
IDがないデータを扱うときはデータを作るときにIDも付与してください。ブラウザでcrypto.randomUUID()を呼び出すと一意のID文字列を作れます。または単純に増加する数字を使ってもよいです(Date.now()など)。

コンポーネントに分離して描く #

<li>の中身が長くなれば別のコンポーネントに分離するのが普通です。このときkeymapが作り出す最上位の要素につけなければならない点を覚えておいてください。

src/TodoItem.jsx
function TodoItem({ todo }) {
  return (
    <li>
      <strong>{todo.text}</strong>  {todo.completed ? '完了' : '進行中'}
    </li>
  );
}

export default TodoItem;
src/TodoList.jsx
import TodoItem from './TodoItem';

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

export default TodoList;

keyTodoItemの中の<li>につけずに、mapが返す<TodoItem>そのものにつけなければなりません。子コンポーネントの内側のどこかにつけるのではなく、リストを作るその箇所(mapのコールバックが返す要素)につけるのです。

filterと組み合わせる #

JavaScriptの配列メソッドは自由に組み合わせられます。たとえば完了していないタスクだけ表示するには、filterで絞り込んだ後にmapをつなげます。

src/TodoList.jsx
function TodoList({ todos }) {
  return (
    <ul>
      {todos
        .filter(todo => !todo.completed)
        .map(todo => (
          <TodoItem key={todo.id} todo={todo} />
        ))}
    </ul>
  );
}

ソートも同じくsort(またはより安全には[...todos].sort(...))とともに使えます。

注記
sortは元の配列を直接変更します。propsで受け取った配列を直接変更することは#4で学んだ「propsは読み取り専用」原則に反し、stateの配列に対しても#5で学んだ「直接変更禁止」原則に反します。ソートが必要なら常に[...todos].sort(...)のようにコピーを作ってソートしてください。

空の配列の処理 #

データが空のとき「空です」というメッセージを表示するには、#7で学んだ条件付きレンダリングと組み合わせます。

src/TodoList.jsx
function TodoList({ todos }) {
  if (todos.length === 0) {
    return <p>タスクがありません</p>;
  }

  return (
    <ul>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

空の<ul>は意味的に不自然なので、early returnで処理する方が自然です。

自分でやってみる #

#6で作ったMessageFormを本物のメッセージリストに発展させてみましょう。今回の記事までに学んだことをすべて使います。

src/MessageForm.jsxを次のように変更します。

src/MessageForm.jsx
import { useState } from 'react';

function MessageForm() {
  const [name, setName] = useState('');
  const [message, setMessage] = useState('');
  const [messages, setMessages] = useState([]);

  const isValid = name.length > 0 && message.length > 0;

  function handleSubmit(e) {
    e.preventDefault();
    if (!isValid) return;
    const newMessage = {
      id: crypto.randomUUID(),
      name,
      message,
      createdAt: new Date().toLocaleTimeString(),
    };
    setMessages(prev => [newMessage, ...prev]);
    setName('');
    setMessage('');
  }

  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          placeholder="名前"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />
        <input
          type="text"
          placeholder="メッセージ"
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          style={{ marginLeft: '8px' }}
        />
        <button type="submit" disabled={!isValid} style={{ marginLeft: '8px' }}>
          追加
        </button>
      </form>

      <div style={{ marginTop: '16px' }}>
        {messages.length === 0 ? (
          <p style={{ color: '#888' }}>まだメッセージがありません</p>
        ) : (
          <ul style={{ listStyle: 'none', padding: 0 }}>
            {messages.map(item => (
              <li
                key={item.id}
                style={{ borderBottom: '1px solid #eee', padding: '8px 0' }}
              >
                <strong>{item.name}</strong>
                <span style={{ color: '#888', marginLeft: '8px', fontSize: '12px' }}>
                  {item.createdAt}
                </span>
                <p style={{ margin: '4px 0 0 0' }}>{item.message}</p>
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  );
}

export default MessageForm;

複数のメッセージを追加してみてください。各メッセージが上から積み重なり、crypto.randomUUID()で作った一意のIDがkeyとして使われます。空の配列のときは案内文が出て、メッセージがあればリストが描かれます。

これまで学んだすべてが1つの画面に混ざっています — props(子要素にデータを渡す)、state(useState)、イベント処理(onSubmitonChange)、条件付きレンダリング(messages.length === 0 ? ... : ...)、そして今回学んだリストレンダリング(map + key)。短いコードですがReactの核心をほとんど見せてくれる例です。

おわりに #

今回の記事では配列を画面に描く方法とkeyの役割を見てきました。ポイントは:

  • 配列はmapでJSX配列を作ってJSXの中に入れる
  • 各要素には一意で安定したkeyが必要
  • 可能ならデータのIDを使い、インデックスkeyはアンチパターン
  • keymapコールバックが返す最上位の要素につける
  • filter、ソート、条件付きレンダリングと自由に組み合わせられる

この記事までがReact基礎講座の第1バッチ(#1〜#8)の締めくくりです。ここまでついてきてくださった方なら、カウンター、トグル、メッセージフォームのような小さなインタラクティブコンポーネントは無理なく作れるようになっているはずです。本当に大きな進歩です。

次の記事「React基礎講座 #9 フォームの扱い」からは、もう少し実戦的なパターンに入っていきます。入力フォームを扱う定石パターン(controlled component)、続く記事ではuseEffect、stateのリフトアップ(lifting state up)、Contextまで順を追って扱っていきます。

X