第8回: 無限スクロールで大量データを表示
124万件を表示する
前回、124万件のデータを2分でインポートすることに成功した。しかし、それをブラウザで表示できなければ意味がない。
本家「桐」のデータベースでは、数十万件のレコードもストレスなく表示される。
初期実装では、以下のように固定で10,000件のみを取得していた:
// 初期実装(問題あり)
const response = await fetch(
`/api/tables/${currentTable}/data?offset=0&limit=10000`
);
const result = await response.json();
setAllData(result.data);
この方法だと、124万件のうち最初の1万件しか見えない。残りの123万件は存在するのに、アクセスできない。
「どうやって124万件を快適に表示するか?」
無限スクロールの設計
いくつかのアプローチを検討した:
アプローチ1: ページネーション(ボタン方式)
[前へ] 1 2 3 ... 12407 [次へ]
問題点:
- 124万件 ÷ 100件/ページ = 12,407ページ
- ページ番号のUIが破綻
- ユーザーが途中のページに移動しづらい
アプローチ2: 仮想スクロール(全データ読み込み)
TanStack Virtual を使えば、大量データでも仮想的にレンダリングできる。しかし、それには全データをメモリに保持する必要がある。
124万件を一度にメモリに載せるのは現実的ではない。
アプローチ3: 無限スクロール(段階的読み込み) ← 採用
スクロールが下部に近づいたら、自動的に次のデータを読み込む方式。
メリット:
- 初回は10,000件だけ読み込むので高速
- ユーザーがスクロールするたびに追加読み込み
- メモリ使用量を抑えられる
デメリット:
- スクロールバーの動きが不自然(データが増えるたびに縮む)
- 全データを見るには何度もスクロールが必要
しかし、実用上は「最初の数万件を見れば十分」というケースが多い。124万件すべてをスクロールして確認する人はほとんどいないだろう。
実装: データ読み込みの状態管理
まず、データ読み込みの状態を管理するための state を定義した:
// app/page.tsx
const [allData, setAllData] = useState<any[]>([]); // 読み込み済みデータ
const [totalRecords, setTotalRecords] = useState<number>(0); // SQLiteの総レコード数
const [isLoadingData, setIsLoadingData] = useState(false); // 初回読み込み中
const [isLoadingMore, setIsLoadingMore] = useState(false); // 追加読み込み中
const [currentOffset, setCurrentOffset] = useState(0); // 現在の読み込み位置
const [hasMore, setHasMore] = useState(true); // まだ読み込めるデータがあるか
const LOAD_LIMIT = 10000; // 1回の読み込み件数
なぜ10,000件なのか?
- 多すぎると: 初回読み込みが遅い、メモリ消費が増える
- 少なすぎると: スクロールのたびにAPIリクエストが発生し、UXが悪化
- 10,000件: 初回1〜2秒で読み込み、数回のスクロールで数万件を表示できる
実装: 初回データ取得
テーブルが切り替わったタイミングで、最初の10,000件を取得する:
// app/page.tsx
useEffect(() => {
const fetchInitialData = async () => {
if (viewMode === 'table' && currentTable) {
setIsLoadingData(true);
setAllData([]);
setCurrentOffset(0);
setHasMore(true);
try {
console.log('[page.tsx] 初期データ取得開始:', currentTable);
const response = await fetch(
`/api/tables/${currentTable}/data?offset=0&limit=${LOAD_LIMIT}`
);
const result = await response.json();
if (result.success) {
console.log('[page.tsx] データ取得完了:', result.data.length, '件 / 総', result.total, '件');
// _rowIdを追加(行選択機能で使用)
const dataWithRowId = result.data.map((row: any, index: number) => ({
...row,
_rowId: index
}));
setAllData(dataWithRowId);
setTotalRecords(result.total);
setCurrentOffset(result.data.length);
setHasMore(result.hasMore);
} else {
console.error('[page.tsx] データ取得エラー:', result.error);
setAllData([]);
setTotalRecords(0);
}
} catch (error) {
console.error('[page.tsx] データ取得に失敗:', error);
setAllData([]);
setTotalRecords(0);
} finally {
setIsLoadingData(false);
}
}
};
fetchInitialData();
}, [viewMode, currentTable, currentView]);
ログを見ると:
[page.tsx] 初期データ取得開始: table_1768221209802
[page.tsx] データ取得完了: 10000 件 / 総 1240703 件
初回は10,000件だけ読み込まれ、総件数が124万件であることが分かる。
実装: 追加データ読み込み
スクロールが下部に近づいたら、次の10,000件を読み込む関数:
// app/page.tsx
const loadMoreData = async () => {
// ガード条件
if (!currentTable || isLoadingMore || !hasMore || viewMode !== 'table') {
return;
}
setIsLoadingMore(true);
try {
console.log('[page.tsx] 追加データ取得:', currentOffset, 'から', LOAD_LIMIT, '件');
const response = await fetch(
`/api/tables/${currentTable}/data?offset=${currentOffset}&limit=${LOAD_LIMIT}`
);
const result = await response.json();
if (result.success && result.data.length > 0) {
// _rowIdを追加(既存データの続きから)
const dataWithRowId = result.data.map((row: any, index: number) => ({
...row,
_rowId: currentOffset + index
}));
// 既存データに追加
setAllData(prev => [...prev, ...dataWithRowId]);
setCurrentOffset(prev => prev + result.data.length);
setHasMore(result.hasMore);
console.log('[page.tsx] 追加データ読み込み完了:', result.data.length, '件(累計:', currentOffset + result.data.length, '件)');
}
} catch (error) {
console.error('[page.tsx] 追加データ取得に失敗:', error);
} finally {
setIsLoadingMore(false);
}
};
ポイント:
-
ガード条件
- 既に読み込み中(
isLoadingMore)なら何もしない - もうデータがない(
!hasMore)なら何もしない - ビューモードでは無限スクロールしない
- 既に読み込み中(
-
offset の管理
-
currentOffsetで現在の読み込み位置を管理 - 次回は
currentOffsetからLOAD_LIMIT件を取得
-
-
データの結合
-
setAllData(prev => [...prev, ...dataWithRowId])で既存データに追加 - React の state 更新は immutable なので、新しい配列を作る
-
実装: スクロールイベントの監視
無限スクロールの核心部分。スクロール位置を監視し、下部に近づいたら loadMoreData() を呼ぶ:
// app/page.tsx (コンポーネント内)
<div
className="flex-1 overflow-auto"
onScroll={(e) => {
const target = e.target as HTMLDivElement;
const scrollTop = target.scrollTop;
const scrollHeight = target.scrollHeight;
const clientHeight = target.clientHeight;
// 下部から200px手前でデータを読み込む
if (scrollHeight - scrollTop - clientHeight < 200 && hasMore && !isLoadingMore) {
loadMoreData();
}
}}
>
<DataTable data={filteredData} columns={columns} />
{/* 追加読み込み中のインジケーター */}
{isLoadingMore && (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span className="ml-2 text-muted-foreground">追加データを読み込み中...</span>
</div>
)}
{/* 完了メッセージ */}
{!hasMore && allData.length > 0 && (
<div className="text-center py-4 text-muted-foreground">
すべてのデータを読み込みました({allData.length.toLocaleString()}件)
</div>
)}
</div>
スクロール判定の仕組み
┌────────────────────────┐ ← scrollTop = 0
│ │
│ 表示領域(viewport) │ clientHeight
│ │
├────────────────────────┤ ← scrollTop
│ │
│ スクロール可能な領域 │
│ │
│ │
└────────────────────────┘ ← scrollHeight
下部までの距離 = scrollHeight - scrollTop - clientHeight
下部から200px手前で読み込みを開始することで、ユーザーがスクロールを止めることなく滑らかにデータが追加される。
TanStack Virtual による仮想スクロール
実は、無限スクロールとは別に、TanStack Virtual による仮想スクロールも併用している。
仮想スクロールとは
通常のテーブル表示では、10,000行すべてをDOMにレンダリングする。これは非常に重い。
仮想スクロールでは、画面に見えている行だけをレンダリングする:
総行数: 10,000行
表示領域: 約20行
実際にレンダリング: 20行 + 前後のバッファ(overscan) = 約60行
これにより、10,000行でも60行分のレンダリングコストで済む。
TanStack Virtual の設定
// components/data-table.tsx
import { useVirtualizer } from '@tanstack/react-virtual';
const tableContainerRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: rows.length, // 総行数
getScrollElement: () => tableContainerRef.current, // スクロールコンテナ
estimateSize: () => 35, // 1行の高さ(ピクセル)
overscan: 20, // 前後のバッファ行数
});
これにより、124万件のうち10万件を読み込んだ状態でも、実際にレンダリングされるのは約60行だけ。スクロールは滑らか。
パフォーマンス最適化
無限スクロールとTanStack Virtualを組み合わせることで、以下のパフォーマンスを実現:
| 段階 | データ量 | レンダリング行数 | メモリ使用量 | スクロール速度 |
|---|---|---|---|---|
| 初回表示 | 10,000件 | 約60行 | 低 | 滑らか |
| 5回スクロール後 | 50,000件 | 約60行 | 中 | 滑らか |
| 10回スクロール後 | 100,000件 | 約60行 | 中 | 滑らか |
| 全データ読み込み | 1,240,703件 | 約60行 | 高 | やや重い |
実際には、100万件以上を一度に表示することは稀。ほとんどのユースケースでは、最初の数万件で絞り込みや検索を行う。
UXの工夫
1. ローディングインジケーター
追加データの読み込み中は、スピナーとテキストを表示:
{isLoadingMore && (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
<span className="ml-2 text-muted-foreground">追加データを読み込み中...</span>
</div>
)}
これにより、ユーザーは「今データを取得しているんだな」と分かる。
2. 完了メッセージ
すべてのデータを読み込んだら、その旨を表示:
{!hasMore && allData.length > 0 && (
<div className="text-center py-4 text-muted-foreground">
すべてのデータを読み込みました({allData.length.toLocaleString()}件)
</div>
)}
これにより、ユーザーは「これ以上スクロールしても意味がない」と分かる。
3. 件数表示
ツールバーに、現在の表示件数と総件数を表示:
表示中: 50,000件 / 総件数: 1,240,703件
これにより、ユーザーは「まだ120万件あるんだな」と全体像を把握できる。
フィルタリングとの併用
無限スクロールは、フィルタリング機能とも併用できる。
フィルタ適用前
表示中: 50,000件 / 総件数: 1,240,703件
フィルタ適用後(学年 = 1年)
絞り込み結果: 5,234件
フィルタが適用されると、読み込み済みデータ内で絞り込みが行われる。もしフィルタ結果が少なくて不十分なら、さらにスクロールして追加データを読み込める。
実際の動作
124万件のテーブルを開いたときの動作:
[初回表示]
- 10,000件を取得(1秒)
- 画面に表示(瞬時)
[スクロール1回目(下部に到達)]
- 次の10,000件を取得(0.5秒)
- 既存データに追加(瞬時)
- 累計: 20,000件
[スクロール2回目]
- 次の10,000件を取得(0.5秒)
- 累計: 30,000件
[以下繰り返し...]
ユーザー体験としては:
「データがたくさんあるけど、スクロールするたびにスムーズに追加されていく。待たされている感じがしない。」
学んだこと
-
一度にすべてを表示する必要はない
- 124万件を一度に表示するのは非現実的
- 段階的に読み込むことで、初回表示を高速化
-
仮想スクロールは必須
- 10万件を超えるデータではDOMレンダリングがボトルネックになる
- TanStack Virtual で実際のレンダリングを抑制
-
UXの配慮
- ローディングインジケーター
- 完了メッセージ
- 件数表示
-
適切なチャンクサイズ
- 10,000件: 初回表示とのバランスが良い
- プロジェクトによって調整が必要
次回、桐の核心機能である補集合と併合の実装について詳しく解説する。