【React】無限スクロールの実装に「tanstack/react-virtual」を使用してみた

【React】無限スクロールの実装に「tanstack/react-virtual」を使用してみた

## 概要
Reactで無限スクロールを実装する際にライブラリはいくつかあるものの、あまりメンテナンスされてないものもあります。ライブラリを使わずに実装しても良いのですが、Tanstackが仮想リストのライブラリ[tanstack/react-virtual](https://tanstack.com/virtual/latest/docs/framework/react/react-virtual)を開発しているようなので、これを使用してみました。tanstack/react-virtualの概要については[TanStack Virtualの素振り](https://zenn.dev/ikuma/scraps/f281db4ff9c34e)の記事が参考になると思います。

## 前提
* 今回使用したtanstack/react-virtualのバージョンは`3.0.4`です。

## 実装してみるもの
公式ドキュメントの[React Example: Infinite Scroll](https://tanstack.com/virtual/v3/docs/framework/react/examples/infinite-scroll)を参考に実装してみます。
以下のイメージのように行はダミーで、100行ずつ追加していくようにします。

<img width="150" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/32098/69cd2d5b-6cd0-7de1-f604-a7efd07e31f1.png" />

## 実装サンプル

```react
"use client";

import { FC, useEffect, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

// テスト用に配列を生成する関数
const makeTestArray = (begin: number, end: number) => {
  return [...Array(end - begin)].map((_, i) => begin + i);
};

export const SampleReactVirtualComponent: FC = () => {
  const [sampleRows, setSampleRows] = useState<number[]>(makeTestArray(1, 101));
  const [isFetching, setIsFetching] = useState<boolean>(false);
  const parentRef = useRef(null);

  const rowVirtualizer = useVirtualizer({
    count: sampleRows.length + 1, // 最後のアイテムは空にしてfetchの判定に使用
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40, // 要素の高さ
    overscan: 10, // 先読みしておくアイテム数
  });

  useEffect(() => {
    // 行を追加する関数
    const fetchRow = async () => {
      setIsFetching(true);

      // テスト用にスリープ
      const my_sleep = (ms: number) =>
        new Promise((resolve) => setTimeout(resolve, ms));
      await my_sleep(3000);
      // 行の追加処理
      const lastNumber = sampleRows[sampleRows.length - 1];
      setSampleRows([
        ...sampleRows,
        ...makeTestArray(lastNumber + 1, lastNumber + 101),
      ]);

      setIsFetching(false);
    };

    const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();

    if (!lastItem) {
      return;
    }

    if (lastItem.index >= sampleRows.length && !isFetching) {
      fetchRow();
    }
  }, [isFetching, rowVirtualizer.getVirtualItems(), sampleRows]);

  return (
    <>
      <div
        ref={parentRef}
        style={{
          height: `500px`,
          overflow: "auto",
          width: "200px",
        }}
      >
        <div
          style={{
            height: `${rowVirtualizer.getTotalSize()}px`,
            position: "relative",
          }}
        >
          {rowVirtualizer.getVirtualItems().map((virtualRow) => {
            const numberRow = sampleRows[virtualRow.index];

            return (
              <div
                key={virtualRow.index}
                style={{
                  position: "absolute",
                  top: 0,
                  left: 15,
                  height: `${virtualRow.size}px`,
                  transform: `translateY(${virtualRow.start}px)`,
                }}
              >
                {numberRow && <>{numberRow}行目</>}
                {!numberRow && <>読み込み中・・</>}
              </div>
            );
          })}
        </div>
      </div>
    </>
  );
};
```