React Virtuosoで仮想スクロール化してブラウザのOutOfMemoryエラーを解決する

はじめに

Next.js で上図のようなテーブルコンポーネントを作成しましたが、画面が重かったり固まったりすることがありました。また、以下のような OutOfMemory エラーがブラウザで発生することがありました。

確認したところ、ブラウザのメモリを大量に使用してしまっているようでした。

メモリ使用量を減らす方法を検討した結果、テーブルの行数を少なくすれば良いことが分かりました。
↓こんな感じで行数が少ないとメモリ使用量が少ない。

テーブルの行数を少なくする方法として、真っ先にページネーションが思いついたのですが、要件的にアンマッチということで断念しました。

いろいろ調べた結果、仮想スクロール という方法を採用することにしました。

仮想スクロールとは

仮想スクロールとは、ブラウザで見えている領域のみレンダリングする技術のことです。
大量のデータを一度に表示する際、レンダリングに時間がかかることでユーザー体験が悪くなることがありますが、仮想スクロールでは見えている範囲+α のレンダリングを実行することでユーザー体験悪化を防ぎます。

参考

仮想スクロールについては引用の通りです。実際に導入したのですが、非常にサクサク動くようになって感動しました。

おすすめライブラリ

自力で仮想スクロール化することもできるはずですが、やはりライブラリを使ったほうが便利だと思います。

まず、目をつけたのが TanStack Virtual です。
テーブルのライブラリに TanStack Table を使用しており、同じ会社が作ったものであれば親和性が高いだろうと考えました。
しかしながら、少し触ってみると思った以上に実装難易度が高かったです。すでに組んであるコンポーネントに後から導入するのは大変だと思いました。

最終的に採用したのは、React Virtuoso です。実際に触れてみると分かるのですが非常に使いやすいです。

React Virtuoso を使ってみる

テーブルコンポーネントの準備

仮想スクロール化していない Next.js サンプルコードを用意しました。

'use client';

import { useEffect, useState } from "react";

type Data = {
    id: number;
    name: string;
    age: number;
    address: string;
    phone: string;
    email: string;
    description: string;
    note: string;
    status: string;
    created: string;
}

export default function Page() {
    const [initialData, setInitialData] = useState<Data[]>([]);
    const [filteredData, setFilteredData] = useState<Data[]>([]);

    useEffect(() => {
        const testData = [];
        for (let i = 1; i <= 10000; i++) {
            testData.push({
                id: i,
                name: `Name ${i}`,
                age: 20 + i,
                address: `Address ${i}`,
                phone: `Phone ${i}`,
                email: `Email ${i}`,
                description: `Description ${i}`,
                note: `Note ${i}`,
                status: `Status ${i}`,
                created: `Created ${i}`
            });
        }
        setInitialData(testData);
        setFilteredData(testData);
    }, []);

    const onSearch = (value: string) => {
        const filteredData = initialData.filter((item) => {
            return item.name.includes(value) ||
                item.address.includes(value) ||
                item.phone.includes(value) ||
                item.email.includes(value) ||
                item.description.includes(value) ||
                item.note.includes(value) ||
                item.status.includes(value) ||
                item.created.includes(value);
        });
        setFilteredData(filteredData);
    }

    return (
        <div>
            <input type="text" onChange={(e) => onSearch(e.target.value)} placeholder="検索..." />
            <div style={{ height: '400px', overflowY: 'auto', position: 'relative' }}>
                <table border={1} style={{ borderCollapse: 'collapse', width: '100%' }}>
                    <thead style={{ position: 'sticky', top: 0, backgroundColor: '#f0f0f0', zIndex: 1 }}>
                        <tr>
                            <th>id</th>
                            <th>name</th>
                            <th>age</th>
                            <th>address</th>
                            <th>phone</th>
                            <th>email</th>
                            <th>description</th>
                            <th>note</th>
                            <th>status</th>
                            <th>created</th>
                        </tr>
                    </thead>
                    <tbody>
                        {filteredData.map((item) => {
                            return (
                                <tr key={item.id}>
                                    <td>{item.id}</td>
                                    <td>{item.name}</td>
                                    <td>{item.age}</td>
                                    <td>{item.address}</td>
                                    <td>{item.phone}</td>
                                    <td>{item.email}</td>
                                    <td>{item.description}</td>
                                    <td>{item.note}</td>
                                    <td>{item.status}</td>
                                    <td>{item.created}</td>
                                </tr>
                            )
                        })}
                    </tbody>
                </table>
            </div>
        </div>
    );
}

テーブルを表示して、セルの文字列検索ができるシンプルなコンポーネントです。

24行目の for (let i = 1; i <= 10000; i++) { で、テーブルに表示する行数を指定しているので適宜変更してください。私の PC では、50000行表示すると OutOfMemory になりました。

仮想スクロール化

まず、ライブラリをインストールします。

npm install react-virtuoso

そして、先ほどのサンプルコードを以下のように変更します。

'use client';

import { useEffect, useState, forwardRef } from "react";
import { TableVirtuoso } from 'react-virtuoso'

type Data = {
    id: number;
    name: string;
    age: number;
    address: string;
    phone: string;
    email: string;
    description: string;
    note: string;
    status: string;
    created: string;
}

export default function Page() {
    const [initialData, setInitialData] = useState<Data[]>([]);
    const [filteredData, setFilteredData] = useState<Data[]>([]);

    useEffect(() => {
        const testData = [];
        for (let i = 1; i <= 10000; i++) {
            testData.push({
                id: i,
                name: `Name ${i}`,
                age: 20 + i,
                address: `Address ${i}`,
                phone: `Phone ${i}`,
                email: `Email ${i}`,
                description: `Description ${i}`,
                note: `Note ${i}`,
                status: `Status ${i}`,
                created: `Created ${i}`
            });
        }
        setInitialData(testData);
        setFilteredData(testData);
    }, []);

    const onSearch = (value: string) => {
        const filteredData = initialData.filter((item) => {
            return item.name.includes(value) ||
                item.address.includes(value) ||
                item.phone.includes(value) ||
                item.email.includes(value) ||
                item.description.includes(value) ||
                item.note.includes(value) ||
                item.status.includes(value) ||
                item.created.includes(value);
        });
        setFilteredData(filteredData);
    }

    return (
        <div>
            <input type="text" onChange={(e) => onSearch(e.target.value)} placeholder="検索..." />

            <TableVirtuoso // 1
                style={{ height: '400px', overflowY: 'auto', position: 'relative' }}
                totalCount={filteredData.length} // 2
                components={{ // 3
                    Table: ({ style, ...props }) => <table border={1} style={{ ...style, borderCollapse: 'collapse', width: '100%' }} {...props} />,
                    TableRow: (props) => {
                        const index = props["data-index"];
                        const row = filteredData[index];

                        return (
                            <tr {...props}>
                                <td>{row.id}</td>
                                <td>{row.name}</td>
                                <td>{row.age}</td>
                                <td>{row.address}</td>
                                <td>{row.phone}</td>
                                <td>{row.email}</td>
                                <td>{row.description}</td>
                                <td>{row.note}</td>
                                <td>{row.status}</td>
                                <td>{row.created}</td>
                            </tr>
                        )
                    },
                    TableHead: forwardRef(function THead(props, ref) {
                        return (
                            <thead ref={ref} {...props} style={{ position: 'sticky', top: 0, backgroundColor: '#f0f0f0', zIndex: 1 }} />
                        )
                    }
                    )
                }}
                fixedHeaderContent={() => { // 4
                    return (
                        <tr>
                            <th>id</th>
                            <th>name</th>
                            <th>age</th>
                            <th>address</th>
                            <th>phone</th>
                            <th>email</th>
                            <th>description</th>
                            <th>note</th>
                            <th>status</th>
                            <th>created</th>
                        </tr>
                    )
                }}
            />
        </div>
    );
}

変更ポイントは以下のとおりです。

  1. テーブルの外側の要素にあてていたスタイルは TableVirtuoso タグに記載します。height を指定しないとテーブルが表示されないことがあるので注意してください。
    <div style={{ height: '400px', overflowY: 'auto', position: 'relative' }}>
    ...
    </div>

    <TableVirtuoso style={{ height: '400px', overflowY: 'auto', position: 'relative' }}
    ...
    />
  2. totalCount は必須です。いま表示されている行数を指定してあげます。
  3. componentstableタグやtbodyタグなどをカスタムできます。
  4. fixedHeaderContentthead の中に表示する tr をカスタムできます。そのフッターバーション fixedFooterContent というのもあります。

以上のポイントさえ抑えていれば、自由度高く仮想スクロール化ができると思います。より詳細な情報が知りたい方は公式ドキュメントを参照ください。

仮想スクロール化したら以下のようになります。見えている範囲のみレンダリングされており、非常にサクサク動くようになります。

おわりに

OutOfMemory エラーが出たとき、最初は処理に時間がかかっている箇所があると思いました。

しかし、そのような箇所は見当たらず、レンダリングする量がメモリ使用量に大きく影響していたことが分かりました。

そういうときは、ぜひ仮想スクロールを活用してみてください。