Tips & Tricks (更新: 2026/6/2)

Claude CodeでReact仮想スクロールを実装する実務ガイド

Claude CodeでReact/TypeScriptの仮想スクロールを実装。TanStack Virtual、可変高さ、検証まで解説。

Claude CodeでReact仮想スクロールを実装する実務ガイド

仮想スクロールが必要になる場面

仮想スクロールは、大量の行をすべてDOMに描画せず、画面に見えている範囲とその前後だけを描画する方法です。英語ではwindowingとも呼ばれます。ここでいうDOMは、ブラウザが画面を作るために持つ要素ツリーです。1万件のログや顧客行をそのままmapで描画すると、見えていない行までレイアウト計算、アクセシビリティツリー、イベント管理の対象になり、スクロールが重くなります。

Claude Codeに「仮想スクロールを作って」とだけ頼むと、固定高さの簡単なサンプルは出ます。しかし実務では、行の高さが変わる、検索条件が変わる、スクロール位置を復元する、キーボードで移動できる、スクリーンリーダーに件数を伝える、モバイル幅で横スクロールを出さない、といった条件が同時に出ます。この記事ではClaude Codeに渡す要件、React/TypeScript実装、TanStack Virtualの使い方、落とし穴、Playwright検査までを一つの作業手順として整理します。

仮想スクロールが特に効くユースケースは、ログビューア、顧客一覧、チャット履歴、検索結果、管理画面テーブルです。ログビューアでは数万行を軽く流し読みできます。顧客一覧ではCRMやSaaS管理画面でページ全体の反応を保てます。チャット履歴では古いメッセージを戻ってもDOMが膨らみません。検索結果では絞り込み後の再描画を抑えられます。管理画面テーブルでは列幅、選択状態、固定ヘッダーと組み合わせやすくなります。

一方で、仮想スクロールは万能ではありません。全件を検索エンジンに読ませたい記事一覧、ユーザーがページ番号を明示的に共有したい画面、総件数が数十件しかない画面では、ページネーションや通常のリストで十分です。無限読み込みと組み合わせる場合は、無限スクロール実装も確認してください。全体的な描画改善はClaude Codeパフォーマンス最適化と一緒に読むと判断しやすくなります。

Claude Codeに最初に渡す要件

実装を頼む前に、Claude Codeへ「何を速くしたいのか」と「何を壊してはいけないのか」を渡します。仮想スクロールは見た目だけでは品質を判断しにくいので、レビュー条件も最初に書いておくのが安全です。

React 18 + TypeScriptでログビューアの仮想スクロールを実装してください。

条件:
- @tanstack/react-virtualを使う
- 1万件以上の行でもDOMに描画する行数を画面周辺だけに抑える
- 行は固定高さ44pxを基本にする
- role、aria-label、aria-posinset、aria-setsizeを入れる
- モバイル幅390pxで横スクロールを出さない
- overscanの理由をコメントではなく説明文にまとめる
- Playwrightでスクロール後の表示と横幅崩れを検査する
- 変更後に公式ドキュメントとの差分リスクをレビューする

この指示では、ライブラリ、件数、アクセシビリティ、横幅、検証方法を同時に指定しています。Claude Codeの出力がずれた場合も、「指定したroleがない」「Playwright検査がない」のようにレビューしやすくなります。特に実務では、デモの速さよりも、あとから検索条件、選択状態、詳細パネル、問い合わせ導線を足しても壊れない構造が大切です。

TanStack Virtualで固定高さのログビューアを作る

2026年6月時点では、Reactで新しく実装するなら@tanstack/react-virtualを第一候補にします。TanStack VirtualはUI部品ではなく、表示範囲の計算をするheadless utilityです。つまり、マークアップとスタイルは自分で管理し、仮想化の計算だけをライブラリに任せます。公式の概要はTanStack Virtual Introduction、設定項目はVirtualizer APIを確認してください。

まず依存関係を入れます。

npm install @tanstack/react-virtual

固定高さのログビューアは、仮想スクロールの基礎を理解するのに向いています。estimateSizeで行の高さを返し、親要素のscrollTopに応じて描画対象だけを出します。

import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

type LogRow = {
  id: string;
  level: "info" | "warn" | "error";
  message: string;
  createdAt: string;
};

export function VirtualLogViewer({ rows }: { rows: LogRow[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const rowVirtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 44,
    overscan: 12,
    getItemKey: (index) => rows[index]?.id ?? index,
  });

  return (
    <section aria-labelledby="log-heading">
      <h2 id="log-heading">Application logs</h2>
      <div
        ref={parentRef}
        data-testid="virtual-log-viewport"
        role="list"
        aria-label={`Application logs, ${rows.length} rows`}
        style={{
          height: 520,
          overflow: "auto",
          border: "1px solid #d4d4d8",
          borderRadius: 6,
        }}
      >
        <div
          style={{
            height: rowVirtualizer.getTotalSize(),
            position: "relative",
            width: "100%",
          }}
        >
          {rowVirtualizer.getVirtualItems().map((virtualRow) => {
            const row = rows[virtualRow.index];
            if (!row) return null;

            return (
              <div
                key={virtualRow.key}
                role="listitem"
                aria-posinset={virtualRow.index + 1}
                aria-setsize={rows.length}
                style={{
                  position: "absolute",
                  top: 0,
                  left: 0,
                  width: "100%",
                  height: `${virtualRow.size}px`,
                  transform: `translateY(${virtualRow.start}px)`,
                  display: "grid",
                  gridTemplateColumns: "92px 72px minmax(0, 1fr)",
                  gap: 12,
                  alignItems: "center",
                  padding: "0 12px",
                  boxSizing: "border-box",
                  borderBottom: "1px solid #eee",
                }}
              >
                <time dateTime={row.createdAt}>{row.createdAt}</time>
                <strong>{row.level.toUpperCase()}</strong>
                <span style={{ overflowWrap: "anywhere" }}>{row.message}</span>
              </div>
            );
          })}
        </div>
      </div>
    </section>
  );
}

ポイントは、外側のスクロール要素と内側の総高さ要素を分けることです。内側のheightは全行が存在するように見せるための高さです。実際にDOMへ置くのはgetVirtualItems()で返った行だけです。overscanは画面外に少し余分に描画する数で、少なすぎると高速スクロール時に白い隙間が見え、多すぎると仮想化の効果が薄れます。ログのように行が軽い場合は8から16程度で試し、画像や複雑なセルがある場合は小さく始めて計測します。

可変高さのチャット履歴を扱う

実務では行の高さが固定ではないことが多いです。チャット履歴では本文の長さ、画像、添付ファイル、翻訳表示、エラーバナーによって高さが変わります。この場合はestimateSizeで仮の高さを渡し、実際のDOMをmeasureElementで測ります。可変高さを自前で配列管理することもできますが、スクロール中の補正や画像ロード後の再計測まで考えると、ライブラリに寄せたほうが安全です。

import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

type Message = {
  id: string;
  author: string;
  body: string;
  avatarUrl?: string;
};

export function VirtualChatHistory({ messages }: { messages: Message[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: messages.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 96,
    overscan: 8,
    getItemKey: (index) => messages[index]?.id ?? index,
  });

  return (
    <div
      ref={parentRef}
      role="log"
      aria-label="Chat history"
      style={{ height: 520, overflow: "auto" }}
    >
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((virtualItem) => {
          const message = messages[virtualItem.index];
          if (!message) return null;

          return (
            <article
              key={virtualItem.key}
              data-index={virtualItem.index}
              ref={virtualizer.measureElement}
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                width: "100%",
                transform: `translateY(${virtualItem.start}px)`,
                padding: "12px 16px",
                boxSizing: "border-box",
              }}
            >
              {message.avatarUrl ? (
                <img
                  src={message.avatarUrl}
                  alt=""
                  width={32}
                  height={32}
                  loading="lazy"
                  onLoad={() => virtualizer.measure()}
                />
              ) : null}
              <p style={{ margin: 0, fontWeight: 700 }}>{message.author}</p>
              <p style={{ margin: "4px 0 0", overflowWrap: "anywhere" }}>
                {message.body}
              </p>
            </article>
          );
        })}
      </div>
    </div>
  );
}

画像が遅れて読み込まれると高さが変わります。onLoadvirtualizer.measure()を呼ぶと再計測できます。添付画像が多いチャットでは、画像にwidthheightを持たせる、プレースホルダーを置く、折りたたみ表示にする、といった設計も必要です。Claude Codeにレビューさせるときは「画像ロード後にスクロール位置が跳ねないか」を必ず検査項目に入れてください。

アクセシビリティとキーボード操作

仮想スクロールは、DOMに存在しない行があるため、支援技術に全体像を伝えにくくなります。すべてを完璧に再現する必要はありませんが、最低限、リストの総件数、現在行の位置、キーボード移動、フォーカスの見失いに配慮します。検索結果や顧客一覧では、上下キーで選択行を動かし、Enterで詳細を開く操作が自然です。

import type { KeyboardEvent } from "react";

type KeyboardParams = {
  activeIndex: number;
  rowCount: number;
  setActiveIndex: (index: number) => void;
  scrollToIndex: (index: number) => void;
};

export function handleVirtualListKeyDown(
  event: KeyboardEvent,
  { activeIndex, rowCount, setActiveIndex, scrollToIndex }: KeyboardParams,
) {
  const lastIndex = Math.max(0, rowCount - 1);
  let nextIndex = activeIndex;

  if (event.key === "ArrowDown") nextIndex = Math.min(lastIndex, activeIndex + 1);
  if (event.key === "ArrowUp") nextIndex = Math.max(0, activeIndex - 1);
  if (event.key === "PageDown") nextIndex = Math.min(lastIndex, activeIndex + 10);
  if (event.key === "PageUp") nextIndex = Math.max(0, activeIndex - 10);
  if (event.key === "Home") nextIndex = 0;
  if (event.key === "End") nextIndex = lastIndex;

  if (nextIndex !== activeIndex) {
    event.preventDefault();
    setActiveIndex(nextIndex);
    scrollToIndex(nextIndex);
  }
}

フォーカスを行そのものに置く設計では、スクロールで行がアンマウントされた瞬間にフォーカスが消えることがあります。実務では、外側コンテナにフォーカスを置き、aria-activedescendantで現在行を示す方法が安定します。ただしスクリーンリーダーの挙動はブラウザとOSで違うため、アクセシビリティ実装ガイドの観点で、キーボード、読み上げ、フォーカスリングを別々に確認してください。

スクロール位置復元とSSR差分

顧客一覧から詳細画面へ移動し、戻ったときに先頭へ戻ると、ユーザーは作業位置を失います。仮想スクロールでは、行番号だけでなくscrollTopや選択IDを復元する設計が必要です。検索条件やソートが変わる場合は、古いスクロール位置を復元すると別の行へ飛ぶので、クエリ条件をキーに含めます。

import { useEffect } from "react";
import type { Virtualizer } from "@tanstack/react-virtual";

type RestoreOptions = {
  storageKey: string;
  getScrollElement: () => HTMLElement | null;
  virtualizer: Virtualizer<HTMLElement, Element>;
};

export function useVirtualScrollRestoration({
  storageKey,
  getScrollElement,
  virtualizer,
}: RestoreOptions) {
  useEffect(() => {
    const savedOffset = sessionStorage.getItem(storageKey);
    if (!savedOffset) return;

    requestAnimationFrame(() => {
      virtualizer.scrollToOffset(Number(savedOffset));
    });
  }, [storageKey, virtualizer]);

  useEffect(() => {
    const element = getScrollElement();
    if (!element) return;

    const save = () => {
      sessionStorage.setItem(storageKey, String(element.scrollTop));
    };

    element.addEventListener("scroll", save, { passive: true });

    return () => {
      save();
      element.removeEventListener("scroll", save);
    };
  }, [getScrollElement, storageKey]);
}

Next.jsやAstroのようにSSRを使う場合は、サーバー側でスクロール要素の実サイズを測れません。初回HTMLとクライアント描画の差が大きいと、hydration後に高さが変わってガタつきます。対策は、クライアントコンポーネントに閉じる、コンテナ高さをCSSで固定する、初期表示件数を少なめにする、estimateSizeを実測に近づける、画像サイズを先に確保する、の順で考えます。

Playwrightで横幅とスクロールを検査する

仮想スクロールは「軽くなった気がする」だけでは公開できません。最低限、モバイル幅、スクロール後の行表示、横幅崩れ、コンソールエラーを自動検査します。以下はデバッグ用ページ/debug/virtual-log-viewerを想定した例です。

import { expect, test } from "@playwright/test";

test("virtual log viewer scrolls without horizontal overflow", async ({ page }) => {
  const errors: string[] = [];
  page.on("console", (message) => {
    if (message.type() === "error") errors.push(message.text());
  });

  await page.setViewportSize({ width: 390, height: 844 });
  await page.goto("/debug/virtual-log-viewer");

  const viewport = page.getByTestId("virtual-log-viewport");
  await expect(viewport).toBeVisible();

  const before = await viewport.boundingBox();
  await viewport.evaluate((node) => {
    node.scrollTop = 2400;
  });
  await expect(page.getByText("Log #250")).toBeVisible();
  const after = await viewport.boundingBox();

  expect(after?.width).toBe(before?.width);
  expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
    await page.evaluate(() => document.documentElement.clientWidth),
  );
  expect(errors).toEqual([]);
});

このテストは「指定行が必ず存在するテストデータ」を用意してから実行します。顧客一覧なら顧客ID、検索結果ならタイトル、チャット履歴ならメッセージIDで確認します。横幅検査は特に重要です。仮想行をposition: absoluteにすると、内側要素の幅指定や長い文字列で本文全体が横に広がることがあります。

概念図とレビュー指示

仮想スクロールの概念は、次のように分けるとClaude Codeにもレビュー担当にも伝えやすくなります。

scrollTop
  -> visible index range
  -> overscan range
  -> virtual rows only
  -> translateYで本来の位置へ配置
  -> measureElementで実高さを補正

実装後は、Claude Codeに次のレビュー指示を渡します。

この仮想スクロール実装をレビューしてください。

確認してほしい点:
- TanStack Virtualの公式APIに沿っているか
- 固定高さと可変高さの責務が混ざっていないか
- overscanが少なすぎて白い隙間が出ないか
- role、aria属性、キーボード操作が破綻していないか
- 画像ロード後に高さズレやスクロールジャンプが起きないか
- 詳細画面から戻ったときのスクロール位置復元があるか
- SSRやhydrationで初回高さが変わらないか
- Playwrightでモバイル幅とスクロール後の表示を検証しているか

Claude Codeに「良さそうですか」と聞くより、失敗条件を列挙したほうが有効です。特にアクセシビリティ、SSR、画像ロード、復元は、見た目のデモだけでは漏れやすい領域です。

よくある落とし穴

落とし穴何が起きるか対策
可変高さを固定高さとして扱う下の行が重なる、スクロール位置がずれるmeasureElementで実測し、画像にもサイズを持たせる
overscan不足高速スクロール時に白い隙間が見える端末性能と行の重さを見て8から16程度で調整する
overscan過多結局DOMが増えて重くなるReact ProfilerとDOM件数で確認する
キーボード操作の欠落マウス以外で行を選べない外側コンテナにフォーカスを置き、上下キーを実装する
スクリーンリーダーへの件数不足現在位置や総件数が伝わらないaria-labelaria-posinsetaria-setsizeを使う
スクロール位置復元なし詳細画面から戻るたびに先頭へ戻る条件ごとのstorageKeyscrollTopを保存する
SSR差分hydration後に高さが変わってガタつくクライアント化、固定コンテナ、実測に近いestimateSizeを使う
長い文字列モバイルで横幅が崩れるminmax(0, 1fr)overflowWrap: "anywhere"を使う

仮想スクロールの失敗は、速度だけでなく「ユーザーが作業位置を失う」「読み上げで意味がわからない」「モバイルでCTAが押せない」という形でも出ます。ClaudeCodeLabのような記事・研修・相談導線を持つサイトでは、検索結果や管理画面が速くても、問い合わせへの流れを邪魔していないかまで見る必要があります。

まとめと次の導線

Claude Codeで仮想スクロールを実装するときは、まずユースケースを決めます。ログビューアなら固定高さで軽く作る、顧客一覧なら選択状態と復元を重視する、チャット履歴なら可変高さと画像ロードを考える、検索結果なら絞り込み後の再描画を確認する、管理画面テーブルなら横幅とキーボード操作を優先する、というように判断が変わります。

ライブラリは、React/TypeScriptなら@tanstack/react-virtualを使うのが現実的です。公式のTanStack Virtual docsVirtualizer APIを基準にし、Claude Codeには実装だけでなくレビュー条件と検証コマンドまで渡します。無限読み込みと合わせる場合は無限スクロール実装、読み上げやキーボード対応はアクセシビリティ実装、描画全体の改善はパフォーマンス最適化へつなげてください。

自社の管理画面、検索結果、ログビューアに仮想スクロールを入れたい場合、Claude Code Labの研修・導入相談では、既存リポジトリを題材に要件整理、実装プロンプト、レビュー観点、Playwright検証まで一緒に整えられます。単発のコード生成ではなく、チームが同じ品質で再現できる手順にすることが目的です。

この記事で紹介した内容を実際に試した結果

固定高さのログビューアでは、通常のrows.mapで1万行を描画した場合に比べ、初回描画後のDOM件数が大きく減り、スクロール中の引っかかりも確認しやすくなりました。一方で、チャット履歴のような可変高さでは、画像ロード後の再計測を入れないとスクロール位置が少し跳ねました。最終的には、estimateSizeを実データに近づける、画像サイズを先に確保する、Playwrightで390px幅とスクロール後の行を検査する、という3点が公開前チェックとして特に効きました。

#Claude Code #仮想スクロール #パフォーマンス #React #ウィンドウイング
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。