👻

Nextjs15・React19で仮想リスト無限スクロール

に公開

やりたいこと

スクロールでデータを見せる。そのときにデータ全取得するのではなく
順次追加取得しながら表示も一致させる。AIに書いてもらいましたが整理したのでメモです。

npm i @tanstack/react-query @tanstack/react-virtual
import { useInfiniteQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";

useInfiniteQueryで順次取得の流れ。

const {data, fetchNextPage} = useInfiniteQuery({
  queryKey: ["cached-key-name"],
  queryFn: ({}) => {
    return getData(pageParam, 30);
  }
  initialPageParam: 0,
  getNextPageParam:(lastPage, lastPageParams) => {
    if (lastPage.poetries.length < 30) return undefined;
    return lastPageParams + 30;
  }
});

これは30件ずつ取得するプログラムです。
queryFnでその30件のデータを受け取るのですが、どこまでという前回どこまで取得したかというか詩織のようなものとしてpageParamを更新する必要があります。

初回以降の呼び出し

初回読み込みの際にqueryFnが実行されますがその後はfetchNextPageで手動で呼び出します。その時にpageParamsが更新されます。
getNextPageParamではデータの末尾チェックの条件式で次のpageがあるのかをチェックします。ここで

const { hasNextPage } = useInfiniteQuery({});

がbooleanに変換するっぽいです。
getNextPageParam => queryFnという流れで詩織が更新されるイメージで理解しました。

いつデータを呼びだすのか?

const allItems = data?.pages.flatMap((p) => page.propety)

30件のデータをひとまとめにします。[][][]... => []

const virtualizer = useVirtualizer({
  count: hasNextPage ? allitem.length + 1 : allItems.length,
  estimateSize: ITEM_WIDTH,
})
const virtualItems = virtualizer.getVirtualItems();

hasNextPageが示す通り, useInfiniteとuseVirtualizerは同期します。ただし注意しなければいけないのは配列の幅だったり順序だったりというのを同期しているのにすぎずデータは保持していません。
getViutualItems()でcountで指定したデータの配列をindexにします。
ここで+1するのは次の要素がある(取得するとき)にあらかじめひとつestimateしていないと更新するコンテナはぎちぎちの状態になるからです。

const { isFetchingNextPage } = useInfiniteQuery({});
useEffect( () => {
  const [lastItem] = [...virtualItems].reverse();
  if(
    lastItem.index >= allItems.length - 1 &&
    hasNextPage && !isFetchingNextPage
  ) {
    fetchNextPage();
  }
})

getViutualItems()はrefしているコンテナに表示されている(widthとindexを照準し)配列を返します。そのためスクロールバーで最後の要素に到達したときにこの条件式は先ほどつくった余白にふれはっかすることになります。
ここで上記で定義したgetNextPageParam => queryFnが実行され、
dataが更新し再レンダリングされallItemが更新されuseEffectに再び入っていきます。
上記が表示の位置に基づくデータ取得の流れです。

vitualizerとHTML

  <div
    ref={parentRef}
    style={{
      position: "relative",
      height: "100dvh",
      width: "90%",
      overflowX: "auto",
      overflowY: "hidden",
      scrollbarColor: "blue",
    }}
  >
    <div
      style={{
        width: `${virtualizer.getTotalSize()}px`,
        position: "relative",
      }}
    >
      {viurualImtes.map((vertualItem) => {
        const isLoaderItem = virtulaItem.inde > allItems.length;
        const poetry = allItem[viurutaltem.index];
        if(isLoadingItem {return}
        {return}
      }
    </div>
  </div>

スクロールコンテナにrefを配置します。その小要素にgetTotalSize()で更新したデータをコンテナに入れていきます。(ここに余白も入っています。)

const vurtializer = useVirtualizer({
  getScrollElement: () => parentRef.current,
  horizontal: true,
  overscan: 0,
})

余白,Lodingを示すのを先ほどの条件式を使い表示したい要素、通常の要素をabsoluteで指定します。

    position: "absolute",
    top: 0,
    left: `${virtualItem.start}px`,
    width: `${virtualItem.size}px`,

leftとwidthはそれぞれestimateで指定した値とgetVirtualItemsのrefでの位相のオブザーブで配置されます。

肝はgetVertualItems()だと思います。ここのデータとの同期性がマジックしているなと感じていただければ実装できるというか、どなたか詰まっている方いましたらAIの素材にしてください。

getDataにタイマーでdelayするとloadingが見えます。

Discussion