Reactでreact-windowを使い、長い一覧を軽く表示する方法
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 にしました。
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 と同じにしています。
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 の一覧は、件数に合わせて行の数も時間も増えました。List は件数を変えても行は13行のままで、時間も 13〜14ms 台でした。見えている高さ 400px に入るのは10行です。残りの3行は、README の overscanCount の説明にある、見えている範囲の外に余分に描く行です。npm で配布されている react-window 2.5.0 のコード(dist)では、overscanCount の初期値は 3 でした。
時間は、この検証環境で測った値です。マシンやブラウザが変われば値も変わります。また、測ったのは最初の描画だけで、スクロール中の滑らかさは測っていません。
スクロールすると、描く行が入れ替わる
1万件の List を、一覧の中ほど(scrollTop を 200,000px に設定)までスクロールして、DOM を調べました。
スクロール前: 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回のスクロールでは、ホイールを少しずつ12回動かしています。下の枠は、撮影用のスクリプトが各コマで DOM を調べて書き足した表示で、一部だけ見えている行も「見えている行」に数えています。値が変わったコマだけ、その値をピンクにしています。スクロール量と見えている行は次々に変わりますが、DOM にある行は、どのコマでも10行から14行のあいだでした。
うまく動かないときに見直す2か所
一覧の高さを決めているか
style で高さを渡さず、高さの決まっていない div の中に List を置くと、1万件のすべての行が DOM にできました。
// 高さを決めずに置いた List
export function NoHeightList({ items }: { items: Item[] }) {
return (
<div>
<List rowComponent={Row} rowCount={items.length} rowHeight={40} rowProps={{ items }} />
</div>
);
}
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 を要素に付け忘れた場合も試しました。
// style を行に渡し忘れた行コンポーネント
function RowWithoutStyle({ index, items }: RowComponentProps<{ items: Item[] }>) {
return <div className="row">{items[index].name}</div>;
}
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 の戻り値を渡せますが、この検証では試していません。





