概要
Reactにおけるリストレンダリングとは、配列データをもとに複数のTSX要素を動的に生成して表示する技術である。
TypeScriptの map() 関数を使用することにより、配列の各要素をTSX要素に変換して画面に表示できる。
リストレンダリングを定義する場合は、各要素に key 属性を付与することが必須となる。
key 属性は、ReactがどのコンポーネントIDが変化したかを識別するために使用する特別な属性であり、適切に設定することでパフォーマンスの最適化とバグの防止につながる。
また、filter() 関数と組み合わせることで条件付きのリストレンダリングを実現でき、入れ子の配列を扱うネストしたリストも実装できる。
下表に、リストレンダリングの手法を示す。
| テクニック |
|---|
map() を使用した配列要素のTSX変換
|
key 属性によるコンポーネント同一性の管理
|
filter() との組み合わせによる条件付き表示
|
| ネストした配列の再帰的なレンダリング |
| 大規模リストに対する仮想化ライブラリの活用 |
map() による繰り返し描画
TypeScriptの map() 関数は、配列の各要素を変換して新しい配列を返す。
Reactでは、この特性を利用して配列データをTSX要素の配列に変換して、コンポーネントとして描画する。
リストレンダリングの基本
文字列配列を map() でリスト要素に変換する基本的な例を以下に示す。
const people: string[] = ['Alice', 'Bob', 'Charlie'];
export default function List() {
const listItems = people.map(person =>
<li key={person}>{person}</li>
);
return <ul>{listItems}</ul>;
}
上記の例では、people 配列の各文字列を
文字列配列の場合は、値自体が一意であれば key 属性の値として使用できる。
数値配列の場合も同様に、
map() で各数値をリスト要素に変換できる。 const numbers: number[] = [1, 2, 3, 4, 5];
export default function NumberList() {
const listItems = numbers.map(number =>
<li key={number}>{number}</li>
);
return <ul>{listItems}</ul>;
}
オブジェクト配列のレンダリング
実際のアプリケーションでは、ユーザ情報等のオブジェクト配列を扱うことが多い。
オブジェクト配列のレンダリング例を以下に示す。
interface Person {
id: number;
name: string;
profession: string;
}
const people: Person[] = [
{ id: 0, name: 'Creola Katherine Johnson', profession: 'mathematician' },
{ id: 1, name: 'Mario José Molina-Pasquel', profession: 'chemist' },
{ id: 2, name: 'Mohammad Abdus Salam', profession: 'physicist' },
];
export default function List() {
const listItems = people.map(person =>
<li key={person.id}>
<p>{person.name}: {person.profession}</p>
</li>
);
return <ul>{listItems}</ul>;
}
オブジェクト配列では、各オブジェクトが持つ一意のID (上記では、person.id) を key 属性に使用するのが最適である。
map() のコールバック関数内では、オブジェクトの各プロパティにアクセスして表示内容を構成できる。
key属性
key 属性は、Reactがリストの各アイテムを識別するために使用する特別な属性である。
適切な key を設定することで、リストの更新・並べ替え・削除を効率的に処理できる。
keyの役割
key 属性は、Reactの差分検出アルゴリズム (Reconciliation) において重要な役割がある。
Reconciliationとは、ReactがVirtual DOMと実際のDOMの差分を検出して、必要な箇所のみを更新するプロセスである。
key 属性の働きは以下の通りである。
- コンポーネントの同一性識別
- Reactは
keyの一致によってコンポーネントが同一のデータを表しているかを識別する。 - これはファイルシステムにおけるファイル名に相当する概念であり、名前が変わればReactは別のコンポーネントと判断する。
- Reactは
- 状態の保持
- データの順序が変わっても、
keyが同じであればコンポーネントの状態 (state) が保持される。 keyが無い場合は位置 (インデックス) で識別されるため、順序変更時にフォーム入力値等の状態が誤ったアイテムに紐づくバグが発生する。
- データの順序が変わっても、
- 再マウントの制御
keyが変わった場合、ReactはそのコンポーネントをアンマウントしてDOMから削除し、新しいコンポーネントとして再マウントする。- これにより、以前の状態は引き継がれずにリセットされる。
keyの選定基準
下表に、適切な key と不適切な key の基準を示す。
| 種別 | 例 | 説明 |
|---|---|---|
| 適切 (最適) | key={person.id} | データベースのID等、データが持つ一意の識別子を使用する。最も推奨される方法。 |
| 適切 | key={crypto.randomUUID()} | ローカルで生成する場合は、インクリメントカウンタやUUIDを使用する。 ただし、コンポーネントの初期化時に1度だけ生成してstateに保持すること。 |
| 不適切 | key={index} | 配列のインデックスをkeyに使用してはならない。 アイテムの削除や並べ替えが発生した場合に、フォームの入力値が誤ったアイテムに紐づくバグが生じる。 |
| 絶対禁止 | key={Math.random()} | レンダリングのたびにkeyが変わるため、全コンポーネントとDOMが毎回再作成される。 パフォーマンスが著しく低下する。 |
| 禁止 | レンダリング中のkey生成 | 関数の実行のたびにkeyが変わるため、コンポーネントが毎回再マウントされてしまう。 |
keyの制約
key 属性には以下に示す制約がある。
| ルール | 説明 |
|---|---|
| 兄弟要素間でのみ一意であればよい |
|
| keyは不変でなければならない | レンダリング中に key が変わってはならない。不変の値のみを key に使用すること。
|
| keyはpropsとして受け取れない |
|
filter() との組み合わせ
filter() 関数は、配列の要素をある条件でフィルタリングして新しい配列を返す。
map() と組み合わせることにより、条件に合致するアイテムのみを表示できる。
条件付きリストレンダリング
filter() と map() を組み合わせた条件付きレンダリングの例を以下に示す。
// Person型は上記で定義済み
const people: Person[] = [
{ id: 0, name: 'Creola Katherine Johnson', profession: 'mathematician' },
{ id: 1, name: 'Mario José Molina-Pasquel', profession: 'chemist' },
{ id: 2, name: 'Mohammad Abdus Salam', profession: 'physicist' },
{ id: 3, name: 'Percy Lavon Julian', profession: 'chemist' },
];
export default function List() {
// chemistのみを抽出
const chemists = people.filter(person =>
person.profession === 'chemist'
);
// 抽出した結果をTSX要素に変換
const listItems = chemists.map(person =>
<li key={person.id}>{person.name}</li>
);
return <ul>{listItems}</ul>;
}
filter() と map() はメソッドチェーンで記述することもできる。
const listItems = people
.filter(person => person.profession === 'chemist')
.map(person => <li key={person.id}>{person.name}</li>);
条件に応じて異なるセクションを表示するコンポーネントに抽出するパターンを以下に示す。
interface ListSectionProps {
title: string;
people: Person[];
}
function ListSection({ title, people }: ListSectionProps) {
return (
<>
<h2>{title}</h2>
<ul>
{people.map(person =>
<li key={person.id}>{person.name}</li>
)}
</ul>
</>
);
}
export default function App() {
const chemists = people.filter(p => p.profession === 'chemist');
const others = people.filter(p => p.profession !== 'chemist');
return (
<>
<ListSection title="化学者" people={chemists} />
<ListSection title="その他" people={others} />
</>
);
}
ネストしたリスト
カテゴリとその配下のアイテムのように、入れ子になった配列構造をレンダリングする場合がある。
ネストしたリストでは、外側と内側の map() それぞれに適切な key を設定する必要がある。
入れ子の配列のレンダリング
カテゴリとアイテムを持つネストしたリストのレンダリング例を以下に示す。
interface Recipe {
id: string;
name: string;
ingredients: string[];
}
const recipes: Recipe[] = [
{
id: 'greek-salad',
name: 'Greek Salad',
ingredients: ['tomatoes', 'cucumber', 'olives', 'feta']
},
{
id: 'hummus',
name: 'Hummus',
ingredients: ['chickpeas', 'olive oil', 'garlic', 'lemon']
},
{
id: 'pizza',
name: 'Pizza',
ingredients: ['pizza crust', 'pizza sauce', 'mozzarella']
},
];
export default function RecipeList() {
return (
<div>
{recipes.map(recipe =>
<div key={recipe.id}>
<h2>{recipe.name}</h2>
<ul>
{recipe.ingredients.map(ingredient =>
<li key={ingredient}>{ingredient}</li>
)}
</ul>
</div>
)}
</div>
);
}
ネストしたリストのポイントを以下に示す。
- 外側の
map()ではrecipe.idをkeyに使用する- データベースIDを使用するため、最適な
keyの選択となる。
- データベースIDを使用するため、最適な
- 内側の
map()ではingredient(材料名の文字列) をkeyに使用する- 同一レシピ内での材料名は一意であるため、文字列を
keyとして使用できる。
- 同一レシピ内での材料名は一意であるため、文字列を
- 各レベルで独立したkeyスコープが存在する
- 外側リストと内側リストの
keyは独立しており、異なるレシピの内側リスト間で同じ材料名が使用されていても問題ない。
- 外側リストと内側リストの
また、複数の要素をラップしつつ余分なDOMノードを追加したくない場合は、Fragment を使用して key を指定できる。
import { Fragment } from 'react';
export default function RecipeList() {
return (
<div>
{recipes.map(recipe =>
<Fragment key={recipe.id}>
<h2>{recipe.name}</h2>
<ul>
{recipe.ingredients.map(ingredient =>
<li key={ingredient}>{ingredient}</li>
)}
</ul>
</Fragment>
)}
</div>
);
}
短縮構文 <> では key を指定できないため、明示的な Fragment コンポーネントを使用する必要がある。
リストレンダリングの注意事項
リストレンダリングを定義する場合は、空配列の処理とパフォーマンスに関する注意が必要である。
空配列の場合の処理
filter() の結果が空配列になる場合 や データが存在しない場合の処理を適切に記述することが重要である。
interface Item {
id: number;
name: string;
}
interface FilteredListProps {
items: Item[];
query: string;
}
export default function FilteredList({ items, query }: FilteredListProps) {
const filteredItems = items.filter(item =>
item.name.includes(query)
);
return (
<ul>
{filteredItems.length > 0 ? (
filteredItems.map(item =>
<li key={item.id}>{item.name}</li>
)
) : (
<li>アイテムが見つかりません</li>
)}
</ul>
);
}
下表に、空配列の処理に関するポイントを示す。
| 項目 | 説明 |
|---|---|
length > 0 による条件分岐 |
配列が空かどうかをチェックして、空の場合はフォールバックUIを表示する。 ユーザにとって何も表示されないよりも、"結果なし"のメッセージを表示する方が親切な設計となる。 |
&& 演算子による短絡評価は避ける |
{items.length && items.map(...)} のような記述では、items.lengthが0の場合、0がそのままレンダリングされるバグが生じる。そのため、三項演算子( ? :)を使用することを推奨する。
|
パフォーマンスの考慮
通常のリストレンダリングは数百件程度のデータであれば問題なく動作する。
しかし、数千件から数万件の大規模なリストになると、全DOMノードの生成と保持によりパフォーマンスが低下する場合がある。
大規模リストに対しては仮想化 (Virtualization) の導入を検討する必要がある。
仮想化とは、画面上に表示されている部分のみをDOMに描画し、スクロールに応じて描画範囲を動的に切り替える技術である。
下表に、代表的な仮想化ライブラリを示す。
| ライブラリ | 特徴 | 推奨用途 |
|---|---|---|
| react-window | 軽量でシンプルなAPI バンドルサイズが小さい。 |
アイテムの高さが固定の場合 |
| react-virtuoso | 高機能で動的な高さに対応 グループ化やフッタ等の機能も持つ。 |
アイテムの高さが動的な場合、高度な機能が必要な場合 |
仮想化ライブラリの使用を検討する目安を以下に示す。
- 100件以下のリスト
- 仮想化は不要
map()によるレンダリングで問題ない。
- 100件から1,000件程度のリスト
- ユーザの端末性能や表示内容の複雑さによって判断する。
- 1,000件以上のリスト
- 仮想化ライブラリの導入を強く推奨する。
関連情報
- Reactの基礎 - TSXの基本構文
- Reactの基礎 - JSXの基本構文
- Reactの基礎 - 条件レンダリング
- Reactの基礎 - コンポーネント
- Reactの基礎 - PropsとChildren