0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

# 桐をWebで蘇らせる ― DataDrawers開発記-第8回

0
Posted at

第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);
  }
};

ポイント:

  1. ガード条件
    • 既に読み込み中(isLoadingMore)なら何もしない
    • もうデータがない(!hasMore)なら何もしない
    • ビューモードでは無限スクロールしない
  2. offset の管理
    • currentOffset で現在の読み込み位置を管理
    • 次回は currentOffset から LOAD_LIMIT 件を取得
  3. データの結合
    • 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件

[以下繰り返し...]

ユーザー体験としては:

「データがたくさんあるけど、スクロールするたびにスムーズに追加されていく。待たされている感じがしない。」


学んだこと

  1. 一度にすべてを表示する必要はない
    • 124万件を一度に表示するのは非現実的
    • 段階的に読み込むことで、初回表示を高速化
  2. 仮想スクロールは必須
    • 10万件を超えるデータではDOMレンダリングがボトルネックになる
    • TanStack Virtual で実際のレンダリングを抑制
  3. UXの配慮
    • ローディングインジケーター
    • 完了メッセージ
    • 件数表示
  4. 適切なチャンクサイズ
    • 10,000件: 初回表示とのバランスが良い
    • プロジェクトによって調整が必要

次回、桐の核心機能である補集合併合の実装について詳しく解説する。

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?