無限スクロール
ルール
- カーソルベースのページネーション: 最後のアイテムのIDまたはタイムスタンプをカーソルとして使用 — オフセットは挿入/削除で破綻するため使用しない
- IntersectionObserver: リスト下部のセンチネル要素にアタッチ — 可視になったらフェッチをトリガー
- 仮想化: 1,000件以上のリストには
@tanstack/react-virtual を使用 — 表示行のみをDOMにレンダリング
- ローディングスケルトン: 次のページをフェッチ中は下部にプレースホルダーアイテムを表示 — スピナーは使わない
- 「さらに読み込む」ボタン: 手動制御を好むユーザーや低速接続のユーザー向けのフォールバックとして提供
- スクロール位置の復元: ナビゲーション前にスクロール位置を保存し、戻る時に復元 —
sessionStorage またはルーターの状態を使用
- 空の状態: リストにゼロ件のときにメッセージを表示 — 空白ページにしない
- リストの終了:
hasNextPage が false のとき「最後まで表示しました」と表示 — オブザーバーを停止
パターン
import { useInfiniteQuery } from "@tanstack/react-query";
import { useRef, useEffect } from "react";
function InfiniteList() {
const sentinelRef = useRef<HTMLDivElement>(null);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ["items"],
queryFn: ({ pageParam }) => fetchItems({ cursor: pageParam, limit: 20 }),
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
initialPageParam: undefined as string | undefined,
});
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting && hasNextPage) fetchNextPage(); },
{ rootMargin: "200px" }
);
if (sentinelRef.current) observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [hasNextPage, fetchNextPage]);
const allItems = data?.pages.flatMap((page) => page.items) ?? [];
return (
<div>
{allItems.map((item) => <ItemCard key={item.id} item={item} />)}
{isFetchingNextPage && <LoadingSkeleton count={3} />}
{!hasNextPage && allItems.length > 0 && <p>最後まで表示しました</p>}
{allItems.length === 0 && !isFetchingNextPage && <EmptyState />}
<div ref={sentinelRef} />
</div>
);
}
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div key={virtualRow.key} style={{
position: "absolute", top: 0, left: 0, width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}>
<ItemCard item={items[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
避けるべき点
- オフセットベースのページネーション — 挿入/削除ですべてのオフセットがずれ、アイテムの重複や欠落が発生する
- 全アイテムを読み込んでからクライアント側でフィルタリング — サーバーから現在のページのみをフェッチする
- 1万以上のDOMノードをレンダリング — 表示行のみをレンダリングするために
@tanstack/react-virtual を使用する
rootMargin なしのIntersectionObserver — 100〜200pxのマージンを追加して、ユーザーが下部に到達する前にプリフェッチする
- リスト終了インジケーターの欠落 — ユーザーはこれ以上読み込むものがないことを知る必要がある