どの B2B SaaS にも、プロダクト全体が集約される画面が一つあります。この記事の題材 — 営業チームが使うメッセージングプラットフォーム — では、それがインボックスです:ユーザーが一日中開きっぱなしにする会話リスト。成熟したワークスペースでは、このリストに約 20,000 件の会話が蓄積されます。そして元のバージョンは、それをすべて一度に読み込んでいました。
この記事では、その画面の再構築をフロントエンドの視点から記録します:オフセットベースのページネーションでは解決しなかった理由、カーソルベースの API コントラクトの設計、React Query と TanStack Virtual による実装、そして受け入れたトレードオフ。結果:ペイロードはリクエストあたり約 6.7 MB から約 19 KB へ(約 1/400)減り、UI のフリーズは消えました。
問題:すべてを一度に読み込む
症状は肉眼で見えるものでした:大きなワークスペースでインボックスを開くと数秒かかり、レンダリング中にインターフェースがフリーズする。DevTools が残りの物語を語ってくれました:約 6.7 MB の単一の JSON レスポンス、そして React が数千行をマウントする間、メインスレッドをブロックする long task の数々が Performance プロファイラに並んでいました。
根本原因はページネーションのないエンドポイントでした:一覧 API はワークスペースの全会話を返し、クライアントはリスト全体をレンダリングしていました。この設計には厄介な性質があります:プロダクトが成功するほど静かに悪化するのです。会話が 500 件なら誰も気づきません。20,000 件になると、プロダクトで最も重要な画面が最も遅い画面になります。
ここには性質の異なる 2 つのコストが混ざっていて、解決策が違うからこそ分けて考える価値があります:ネットワークとシリアライズのコスト(6.7 MB のダウンロードとパース)と、レンダリングのコスト(約 20,000 個のコンポーネントを DOM にマウントし続けること)。ページネーションが解決するのは前者だけ。後者を解決できるのは仮想化だけです。
なぜオフセットベースではないのか
最初に思いつくのは ?page=3&limit=50 でしょう。しかしインボックスでは、オフセットベースのページネーションは 2 つの点で破綻します:
- 読み取りコストの増大 —
OFFSET 10000は、次の 50 行を返す前にデータベースに 10,000 行を走査して捨てさせます。ユーザーが深くナビゲートするほどページは遅くなります。 - 同時書き込み下での不安定さ — インボックスには新しいメッセージが絶えず届き、挿入のたびにオフセットがずれます:ページをまたいで項目が重複したり消えたりする。誰にも再現できない断続的なバグの典型です。
カーソルベース(keyset)のページネーションは両方を解決します:行を数える代わりに、各ページは最後に読み込んだレコードへの安定したポインタから始まります。読み取りコストは一定に保たれ、新しい挿入があっても何もずれません。
API コントラクト
カーソルはクライアントにとって不透明です:バックエンドだけが解釈できるエンコード済み文字列 — 実際にはソート可能なペア (updated_at, id) で、同じタイムスタンプのレコードは id で順序を確定します。フロントエンドが消費するコントラクト:
// GET /conversations?workspaceId=...&cursor=...&limit=50
type ConversationsPage = {
items: ConversationPreview[]
nextCursor: string | null // null => last page
}
// The list never receives the full conversation:
// only the fields the row actually renders
type ConversationPreview = {
id: string
contactName: string
lastMessageSnippet: string
lastMessageAt: string
unreadCount: number
status: "open" | "closed" | "snoozed"
}不釣り合いなほど効く細部がひとつ:リストは完全な会話オブジェクトを受け取らず、行が表示するフィールドだけを受け取ります。ConversationPreview 型はまさにそのために存在します — 一覧のペイロードが会話の完全なモデルと一緒に肥大化するのを防ぐのです。ペイロードシェイピングとページネーションの組み合わせこそが、1 ページ約 19 KB を生み出します。
React Query によるデータフェッチング
useInfiniteQuery はまさにこの形のために作られています:各ページは getNextPageParam で次のページの出どころを宣言し、キャッシュ、重複排除、ローディング状態はライブラリが引き受けます:
function useConversations(workspaceId: string) {
return useInfiniteQuery({
queryKey: ["conversations", workspaceId],
queryFn: ({ pageParam }) =>
fetchConversations({ workspaceId, cursor: pageParam, limit: 50 }),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
}
// Pages arrive as an array of arrays;
// the list consumes a single flat array
const allRows = data ? data.pages.flatMap((page) => page.items) : []この設計の要点は、蓄積が DOM ではなくキャッシュで起きることです:インボックスを深くたどると React Query にページが追加されますが — 次のセクションで示すように — レンダリングされる要素の数は変わりません。
TanStack Virtual による仮想化
ページネーションだけではペイロードは減ってもレンダリングコストは減りません:十分にスクロールすれば、蓄積されたページが DOM の中に元の問題を一行ずつ再構築してしまいます。答えはウィンドウイングです — ビューポートに見えている行(と少しのオーバースキャン)だけをレンダリングし、リストの全高を保持するコンテナの中に絶対配置する。これでスクロールバーは正直なままです。
@tanstack/react-virtual を使うと、useVirtualizer はページネーションの自然なトリガーにもなります:最後の仮想アイテムが読み込み済みリストの末尾に近づいたら、次のページを要求します:
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: hasNextPage ? allRows.length + 1 : allRows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 88, // row height estimate; measureElement refines it
overscan: 8,
})
const virtualItems = virtualizer.getVirtualItems()
useEffect(() => {
const last = virtualItems.at(-1)
if (!last) return
if (last.index >= allRows.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}, [virtualItems, allRows.length, hasNextPage, isFetchingNextPage, fetchNextPage])
return (
<div ref={parentRef} className="h-full overflow-auto">
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualItems.map((row) => (
<ConversationRow
key={allRows[row.index]?.id ?? "loader"}
ref={virtualizer.measureElement}
data-index={row.index}
conversation={allRows[row.index]}
style={{
position: "absolute",
top: 0,
transform: `translateY(${row.start}px)`,
}}
/>
))}
</div>
</div>
)このコードの 2 つの判断には説明の価値があります。count は次のページがあるとき 1 行分多くしてあります — それがリスト末尾のローディングスケルトンになります。そして measureElement はマウント後に各行の実際の高さを測ります。インボックスの行は高さが固定ではないからです(1〜2 行のスニペット、バッジ、状態表示)。estimateSize は最初の描画に足りる程度の精度で十分です。
結果
- リクエストあたりのペイロード:約 6.7 MB から約 19 KB へ(約 1/400)
- UI フリーズの解消 — DOM が約 10〜15 行で安定し、レンダリングの long task はプロファイラから消えた
- 最大規模のワークスペースでも、インボックスを開くのに数秒かかることはなくなった
- 画面のコストがワークスペースの規模に比例しなくなった:会話が 500 件でも 20,000 件でも、フレームあたりの仕事は同じ
受け入れたトレードオフ
これらの選択はどれも無償ではありません。失うものを記録しておく価値があります:
- 任意のページへジャンプできない — カーソルは「37 ページ目へ連れて行って」に答えられません。検索とフィルタを備えた時系列ストリームとして使われるインボックスでは問題になりませんでしたが、管理用テーブルなら実害があります。
- 仮想化は本物の複雑さを持ち込む — 動的な高さの計測、スクロール位置の復元、調整されたオーバースキャン。リストが本当に大きいときにだけ元が取れるエンジニアリングコストです。
- DOM にないものはブラウザにとって存在しない — ページの Cmd+F はレンダリングされていない行を見つけられず、スクリーンリーダーには追加の配慮(件数のための
aria-setsize/aria-posinset)が必要です。プロダクト自身の検索が本当の検索になります。
これらを使うべきでないとき
リストが数百件程度なら、ペイロードシェイピングとシンプルなページネーションで止めるべきです — 仮想化は数千行のための答えであって、デフォルトのパターンではありません。私が使うようになった物差し:プロダクトの利用とともに上限なく伸びるリストにはカーソルベースのページネーション、単一のリストが DOM で約 1,000 ノードを超えそうなら仮想化。
二周目で注意すべきこと
このアーキテクチャを実装するなら必ず気を配りたい 2 点:ナビゲーション間のスクロール復元 — 会話からリストに戻ったとき、ユーザーは元いた場所に正確に戻るべきで、仮想化はアンマウントを生き延びないため、オフセットをコンポーネントの外に永続化する必要があります。そしてリアルタイム更新の外科的な無効化:WebSocket で届く新着メッセージは setQueryData でキャッシュの正しいページを更新すべきで、クエリ全体を無効化してページネーションが節約したばかりの仕事をやり直させてはいけません。
残る教訓はライブラリの話というより、コストを分けて考える話です:ネットワーク、シリアライズ、レンダリングはそれぞれ異なるメカニズムで劣化し、異なる道具で解決されます。カーソルベースのページネーションは「何が流れるか」を、仮想化は「何が描画されるか」を受け持つ。この 2 つが組み合わさって、プロダクトで最も重い画面は最も安定した画面になり — そのコストは顧客の成長に比例しなくなりました。
