Reactでreact-windowを使い、長い一覧を軽く表示する方法

​​React​Playwright,react-window,TypeScript

1万件の一覧を map でそのまま描くと、画面に見えるのが10行ほどでも、DOM には1万行ができます。react-window の List を使うと、見えている行とその前後の数行だけを DOM に置き、スクロールに合わせて入れ替えます。

検証環境で試したところ、10万件の一覧でも DOM にある行は13行で、最初の描画は 13.2ms でした。同じ10万件をすべて描くと、1,471.1ms かかりました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、react-window 2.5.0、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で本番用にビルドし、Chromium 141(Playwright 1.56.1 で操作)で動かしました。StrictMode では包んでいません。

List に渡すのは、行のコンポーネントと件数と高さ

react-window は npm install react-window で入れます。README の TypeScript の型についての説明にあるとおり、型の定義はパッケージに入っているので、別に @types/react-window を入れる必要はありません。

次のコードは、items の名前を1行ずつ出す一覧です。1行の高さは 40px、一覧の高さは 400px にしました。

tsx
import { List, type RowComponentProps } from 'react-window';
import type { Item } from './items';

// 1行ぶんのコンポーネント。index と style は List から渡される
function Row({ index, style, items }: RowComponentProps<{ items: Item[] }>) {
  return (
    <div className="row" style={style}>
      {items[index].name}
    </div>
  );
}

// 見えている行だけを描く一覧
export function WindowList({ items }: { items: Item[] }) {
  return (
    <List
      rowComponent={Row}
      rowCount={items.length}
      rowHeight={40}
      rowProps={{ items }}
      style={{ height: 400 }}
    />
  );
}

List に渡しているものは次の5つです。

プロパティ 渡すもの
rowComponent 1行を描くコンポーネント
rowCount 一覧の件数
rowHeight 1行の高さ(px)
rowProps 行のコンポーネントに渡したい値
style 一覧そのものの高さなど

行のコンポーネントには、List から行の番号 index と、行を置く位置の style が渡されます。rowProps に入れた値(ここでは items)も、同じ props として届きます。行の props の型は RowComponentProps で書けます。どれも GitHub の README にある List の説明のとおりです。

Item は { id: number; name: string } の型で、名前は「商品 1」「商品 2」のように付けました。

すべての行を描く一覧との比較

比べる相手として、map ですべての行を描く一覧も作りました。高さとスクロールの指定は、List と同じにしています。

tsx
import type { Item } from './items';

// すべての行を描く、ふつうの一覧
export function PlainList({ items }: { items: Item[] }) {
  return (
    <div style={{ height: 400, overflowY: 'auto' }}>
      {items.map((item) => (
        <div key={item.id} className="row" style={{ height: 40 }}>
          {item.name}
        </div>
      ))}
    </div>
  );
}

Chromium で、それぞれを 1,000件、1万件、10万件で描きました。DOM にある行(.row)の数と、描き始めてから次の描画フレームまでの時間を測っています。時間は、ページを開き直して5回測った中央値です。

件数 map の行の数 map の時間 List の行の数 List の時間
1,000件 1,000 25.0ms 13 13.6ms
1万件 10,000 158.9ms 13 14.2ms
10万件 100,000 1,471.1ms 13 13.2ms
件数ごとの描画時間の横棒グラフ。map は1,000件で25.0ms、1万件で158.9ms、10万件で1,471.1ms。List はどの件数でも13〜14ms台
描き始めてから次の描画フレームまでの時間(5回の中央値)

map の一覧は、件数に合わせて行の数も時間も増えました。List は件数を変えても行は13行のままで、時間も 13〜14ms 台でした。見えている高さ 400px に入るのは10行です。残りの3行は、README の overscanCount の説明にある、見えている範囲の外に余分に描く行です。npm で配布されている react-window 2.5.0 のコード(dist)では、overscanCount の初期値は 3 でした。

時間は、この検証環境で測った値です。マシンやブラウザが変われば値も変わります。また、測ったのは最初の描画だけで、スクロール中の滑らかさは測っていません。

スクロールすると、描く行が入れ替わる

1万件の List を、一覧の中ほど(scrollTop を 200,000px に設定)までスクロールして、DOM を調べました。

text
スクロール前: scrollTop=0 見える高さ=400 全体の高さ=400000 .rowの数=13 最初=商品 1 最後=商品 13
  最初の行の style: position: absolute; left: 0px; transform: translateY(0px); height: 40px; width: 100%;
scrollTop=200000: scrollTop=200000 見える高さ=400 全体の高さ=400000 .rowの数=16 最初=商品 4998 最後=商品 5013
  最初の行の style: position: absolute; left: 0px; transform: translateY(199880px); height: 40px; width: 100%;
  DOM に「商品 5005」があるか=true、「商品 100」があるか=false

スクロールできる全体の高さは、1万件 × 40px の 400,000px になっていました。スクロール量と行の高さから計算すると、見えているのは「商品 5001」から「商品 5010」です。DOM にあるのは、その10行に前後3行ずつを足した16行でした。各行には List が style で position: absolute と transform: translateY(...) を付け、全体の中の正しい位置に置いていました。見えていない「商品 100」の文字は、DOM のどこにもありません。

ホイールでスクロールしたときの様子を、GIF にしました。この GIF では、style で渡す一覧の高さを 280px にしています。見えるのは7行で、スクロール前に DOM にあるのは、その下に3行を足した10行です。

1万件の List をホイールで3回スクロールする様子。スクロール量と見えている行は変わっていくが、DOM にある行は10行から14行のあいだにとどまる
ホイールでスクロールしたときの、見えている行と DOM にある行の数

1回のスクロールでは、ホイールを少しずつ12回動かしています。下の枠は、撮影用のスクリプトが各コマで DOM を調べて書き足した表示で、一部だけ見えている行も「見えている行」に数えています。値が変わったコマだけ、その値をピンクにしています。スクロール量と見えている行は次々に変わりますが、DOM にある行は、どのコマでも10行から14行のあいだでした。

うまく動かないときに見直す2か所

一覧の高さを決めているか

style で高さを渡さず、高さの決まっていない div の中に List を置くと、1万件のすべての行が DOM にできました。

tsx
// 高さを決めずに置いた List
export function NoHeightList({ items }: { items: Item[] }) {
  return (
    <div>
      <List rowComponent={Row} rowCount={items.length} rowHeight={40} rowProps={{ items }} />
    </div>
  );
}
text
NoHeightList: .rowの数=10000 List の高さ=400000

react-window 2.5.0 の dist のコードを見ると、List の外側の要素には、初めから maxHeight: "100%" と overflowY: "auto" が付き、style で渡した値がその後に重なります。高さを渡さないと、高さの上限は maxHeight: "100%" だけです。この検証では親の高さが決まっていないので、List は全行ぶんの 400,000px まで伸びました。全行が「見えている」範囲に入るため、すべてを描いたと考えられます。README の style の説明にあるように、行の一覧は、この style で決めた高さいっぱいに広がります。style で高さを渡してください。dist のコードからは、高さの決まった親の中に置いても maxHeight: "100%" で親の高さに収まると読めますが、この検証では試していません。

行のコンポーネントで style を付けているか

行のコンポーネントで、受け取った style を要素に付け忘れた場合も試しました。

tsx
// style を行に渡し忘れた行コンポーネント
function RowWithoutStyle({ index, items }: RowComponentProps<{ items: Item[] }>) {
  return <div className="row">{items[index].name}</div>;
}
text
ForgotStyleList スクロール前: .rowの数=13 先頭3行の上端(px)=0, 40, 80
ForgotStyleList scrollTop=200000: .rowの数=16 DOM の最初の行=商品 5001 枠の中に見えている行の数=0

スクロールする前は、行の上端が 0px、40px、80px で、上から順に並んでいました。ところが、中ほどまでスクロールすると、DOM には16行あるのに、一覧の枠の中に見えている行は0行で、一覧は空白になりました。style を付けない行は位置を指定されないので、スクロールした先の位置には行が置かれないため、何も表示されないと考えられます。

ポイント

List が軽くならないときや空白になるときは、List の高さが決まっているかと、行のコンポーネントが style を要素に付けているかを、最初に確かめてください。

1.x の FixedSizeList から書き換えるとき

react-window 1.x の書き方では、FixedSizeList や、大きさを測るための AutoSizer(react-virtualized-auto-sizer)が出てきます。CHANGELOG の 2.0.0 の項目によると、2.0.0 は作り直しで、List の大きさは自動で決まるため AutoSizer は要らなくなりました。ただし、前の節のとおり、高さを決めずに置くと全行が描かれます。TypeScript の型もパッケージに入り、@types/react-window は要らなくなっています。

CHANGELOG にある FixedSizeList の書き換えの例では、プロパティが次のように変わっています。

1.x の FixedSizeList 2.x の List
children(行のコンポーネント) rowComponent
itemCount rowCount
itemSize rowHeight
itemData(行には data として届く) rowProps(行には props として届く)

筆者は 1.x の書き方は動かしていません。この表は CHANGELOG の例から読み取ったものです。また、行ごとに高さが違う一覧では、README にあるように rowHeight に関数や useDynamicRowHeight の戻り値を渡せますが、この検証では試していません。

​Posted by 86