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

Reactのテーブル実装でソート・ページネーション・仮想化を詰める

Reactのデータテーブルをソート・ページネーション・列フィルタ・仮想化まで実装。自前の最小コードとTanStack Tableの使い分け、アクセシブルなtableマークアップを紹介。

Reactのテーブル実装でソート・ページネーション・仮想化を詰める

「顧客一覧、表で出しといて」

その一言で僕が最初に作ったのは、divを縦に並べただけの“それっぽい表”でした。見た目はきれい。デモも通った。ところが翌週、上司から飛んできたのは「MRRで並べ替えたい」「名前で検索したい」「100件あるから分割して」「スマホで見ると横スクロールがつらい」。全部あとから入れる羽目になって、コンポーネントはぐちゃぐちゃになりました。

テーブルが地味に難しいのは、最初の<table>が一番簡単で、そこから先がほぼ無限に伸びるからです。ソート、ページネーション、列フィルタ、列リサイズ、何千行もの仮想化。どこまで自前で書いて、どこからライブラリに任せるか。今日はその線引きを、コピペで動くコードと一緒に整理します。

この記事の要点

  • 5〜8列・数百行までなら、ReactのuseStateuseMemoだけでソート・フィルタ・ページネーションは十分まかなえる。下に丸ごと動くコードを置いた。
  • ソートの矢印を表示するだけだと支援技術に伝わらない。今ソート中の列にだけaria-sortを付けるのが正解。
  • 列の表示切り替え・列リサイズ・サーバーサイドページング・行選択まで来たら、ヘッドレスのTanStack Tableに寄せる。見た目は自分のCSSのまま使える。
  • 数千行を一度に描画するとスクロールが固まる。そのときだけ行の仮想化(画面に映る分だけDOMを作る)を足す。
  • フィルタを変えたらpageを1に戻す。これを忘れると「2ページ目が空でデータがないように見える」という定番バグになる。

まず「これは本当に表か」を疑う

実装の前に一回だけ立ち止まってほしいことがあります。そのデータ、本当にテーブルですか。

<table>は、行と列の交点に意味があるデータのためのHTMLです。顧客名・プラン・月額・状態・登録日のように「列ごとに比較したい」なら表が正解。一方、画像つきの商品カードをグリッドに並べたいだけなら、ulやCSS Gridのほうが素直です。見た目を表っぽくしたいだけで<table>を選ぶと、レスポンシブで苦労します。

表だと決めたら、最低限これを入れます。

  • caption:表が何の一覧かを一言で説明する
  • thead / tbody:見出し行と本体を分ける
  • th scope="col":「これは列見出し」という宣言
  • th scope="row":行の主語になるセル(顧客名など)
<table>
  <caption>顧客ごとの月額売上</caption>
  <thead>
    <tr>
      <th scope="col">顧客名</th>
      <th scope="col">プラン</th>
      <th scope="col">月額売上</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Northwind</th>
      <td>Pro</td>
      <td>$1,200</td>
    </tr>
  </tbody>
</table>

この構造は後から足すと高くつきます。divで組んだものをtableに直すのは、ほぼ書き直し。だから最初の30分でここを決めておくと、あとが楽になります。a11yの優先順位そのものはWebアクセシビリティ実装の優先順位に詳しくまとめたので、迷ったら先にそちらを。

自前で書くか、ライブラリに乗るか

僕の中の判断基準はシンプルで、「半年後にどこまで化けるか」だけです。

選択肢向いている場面つらくなる所
自前実装5〜8列、検索と単純なソート、数百行まで列設定・固定列・サーバー連携が来ると状態が散らかる
TanStack Table列表示切替・複数フィルタ・行選択・仮想化・サーバーページングAPIを覚える必要がある/UIは自分で書く
AG Gridなど重量級Excelに近い編集、巨大データ、業務専用機能導入コストとライセンス確認

ポイントは、TanStack Tableがヘッドレスだということ。これは「見た目の部品は付いてこない、テーブルの状態(ソート・フィルタ・ページ)だけを作ってくれる」という意味です。ボタンや枠線のデザインは自分のCSSやデザインシステムのまま。だから「既存の見た目を壊さず、状態管理だけ強くしたい」ときに刺さります。公式もTanStack Table公式ドキュメントの冒頭で headless UI library だと明言しています。

最初から重いライブラリを入れる必要はありません。迷ったら自前で軽く始めて、要件が増えた瞬間に寄せる。次のコードはその「軽く始める」版です。

コピペで動くReactテーブル(ソート・フィルタ・ページネーション)

ライブラリなしで動く最小実装です。Next.jsのクライアントコンポーネントでも、ViteのReactアプリでもそのまま動きます。Next.js以外なら先頭の"use client";は消してかまいません。

// src/components/DataTable.tsx
"use client";

import { useMemo, useState, type ReactNode } from "react";
import "./data-table.css";

type SortDirection = "asc" | "desc";

type SortState<T> = {
  key: keyof T;
  direction: SortDirection;
} | null;

export type Customer = {
  id: string;
  name: string;
  plan: "Free" | "Pro" | "Enterprise";
  mrr: number;
  status: "active" | "trial" | "paused";
  signedUpAt: string;
};

type Column<T> = {
  key: keyof T;
  label: string;
  numeric?: boolean;
  render?: (value: T[keyof T], row: T) => ReactNode;
};

const pageSize = 5;

const sampleCustomers: Customer[] = [
  { id: "cus_001", name: "Northwind", plan: "Pro", mrr: 1200, status: "active", signedUpAt: "2026-01-15" },
  { id: "cus_002", name: "Blue Bottle", plan: "Free", mrr: 0, status: "trial", signedUpAt: "2026-02-02" },
  { id: "cus_003", name: "Kobayashi Studio", plan: "Enterprise", mrr: 8400, status: "active", signedUpAt: "2025-11-20" },
  { id: "cus_004", name: "Atlas Foods", plan: "Pro", mrr: 980, status: "paused", signedUpAt: "2025-12-09" },
  { id: "cus_005", name: "Green Lab", plan: "Pro", mrr: 1600, status: "active", signedUpAt: "2026-03-01" },
  { id: "cus_006", name: "Sakura Dental", plan: "Free", mrr: 0, status: "trial", signedUpAt: "2026-03-18" },
];

const currency = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
});

const columns: Column<Customer>[] = [
  { key: "name", label: "Customer" },
  { key: "plan", label: "Plan" },
  {
    key: "mrr",
    label: "MRR",
    numeric: true,
    render: (_, row) => currency.format(row.mrr),
  },
  {
    key: "status",
    label: "Status",
    render: (_, row) => <span className={`status status-${row.status}`}>{row.status}</span>,
  },
  {
    key: "signedUpAt",
    label: "Signed up",
    render: (_, row) => new Date(row.signedUpAt).toLocaleDateString("en-US"),
  },
];

// 数値は引き算、文字列はロケール比較。null安全を優先するなら別途ガードを足す
function compareValues<T>(leftRow: T, rightRow: T, key: keyof T) {
  const left = leftRow[key];
  const right = rightRow[key];

  if (typeof left === "number" && typeof right === "number") {
    return left - right;
  }

  return String(left).localeCompare(String(right), undefined, {
    numeric: true,
    sensitivity: "base",
  });
}

export function DataTable({ rows = sampleCustomers }: { rows?: Customer[] }) {
  const [query, setQuery] = useState("");
  const [page, setPage] = useState(1);
  const [sort, setSort] = useState<SortState<Customer>>({
    key: "name",
    direction: "asc",
  });

  // 1) フィルタ:全列を対象に部分一致
  const filteredRows = useMemo(() => {
    const keyword = query.trim().toLowerCase();
    if (!keyword) return rows;

    return rows.filter((row) =>
      columns.some((column) =>
        String(row[column.key]).toLowerCase().includes(keyword),
      ),
    );
  }, [query, rows]);

  // 2) ソート:フィルタ後の配列を並べ替える
  const sortedRows = useMemo(() => {
    if (!sort) return filteredRows;

    return [...filteredRows].sort((left, right) => {
      const result = compareValues(left, right, sort.key);
      return sort.direction === "asc" ? result : -result;
    });
  }, [filteredRows, sort]);

  // 3) ページネーション:並べ替え後を切り出す
  const totalPages = Math.max(1, Math.ceil(sortedRows.length / pageSize));
  const currentPage = Math.min(page, totalPages);
  const pageRows = useMemo(() => {
    const start = (currentPage - 1) * pageSize;
    return sortedRows.slice(start, start + pageSize);
  }, [currentPage, sortedRows]);

  // フィルタを変えたら必ず1ページ目へ戻す(空表示バグの予防)
  function updateQuery(value: string) {
    setQuery(value);
    setPage(1);
  }

  function toggleSort(key: keyof Customer) {
    setSort((current) => {
      if (!current || current.key !== key) {
        return { key, direction: "asc" };
      }

      return {
        key,
        direction: current.direction === "asc" ? "desc" : "asc",
      };
    });
  }

  return (
    <section className="table-shell" aria-labelledby="customers-table-title">
      <div className="table-actions">
        <h2 id="customers-table-title">Customers</h2>
        <label>
          <span>Filter customers</span>
          <input
            type="search"
            value={query}
            onChange={(event) => updateQuery(event.target.value)}
            placeholder="Search name, plan, or status"
          />
        </label>
      </div>

      <div className="table-scroll" tabIndex={0}>
        <table className="data-table">
          <caption>Monthly recurring revenue by customer</caption>
          <thead>
            <tr>
              {columns.map((column) => {
                const isSorted = sort?.key === column.key;
                const ariaSort = isSorted
                  ? sort.direction === "asc"
                    ? "ascending"
                    : "descending"
                  : undefined;

                return (
                  <th
                    key={String(column.key)}
                    scope="col"
                    aria-sort={ariaSort}
                    className={column.numeric ? "numeric" : undefined}
                  >
                    <button type="button" onClick={() => toggleSort(column.key)}>
                      {column.label}
                      <span aria-hidden="true">
                        {isSorted ? (sort.direction === "asc" ? " ▲" : " ▼") : ""}
                      </span>
                    </button>
                  </th>
                );
              })}
            </tr>
          </thead>
          <tbody>
            {pageRows.length > 0 ? (
              pageRows.map((row) => (
                <tr key={row.id}>
                  {columns.map((column, index) => {
                    const content = column.render
                      ? column.render(row[column.key], row)
                      : String(row[column.key]);

                    if (index === 0) {
                      return (
                        <th key={String(column.key)} scope="row" data-label={column.label}>
                          {content}
                        </th>
                      );
                    }

                    return (
                      <td
                        key={String(column.key)}
                        data-label={column.label}
                        className={column.numeric ? "numeric" : undefined}
                      >
                        {content}
                      </td>
                    );
                  })}
                </tr>
              ))
            ) : (
              <tr>
                <td colSpan={columns.length}>No customers match this filter.</td>
              </tr>
            )}
          </tbody>
        </table>
      </div>

      <nav className="pagination" aria-label="Table pagination">
        <button type="button" onClick={() => setPage((value) => value - 1)} disabled={currentPage === 1}>
          Previous
        </button>
        <span aria-live="polite">
          Page {currentPage} of {totalPages}
        </span>
        <button
          type="button"
          onClick={() => setPage((value) => value + 1)}
          disabled={currentPage === totalPages}
        >
          Next
        </button>
      </nav>
    </section>
  );
}

ここで一番おさえてほしいのが、データを フィルタ → ソート → ページ切り出し の順番で流していること。この順番を間違えると、「検索したのに2ページ目に出てこない」「ソートしたら別ページの行が混ざる」みたいな再現しづらいバグが出ます。useMemoを3段に分けているのは、それぞれの依存配列をはっきりさせて、無駄な再計算を止めるためです。

そしてソートの状態。aria-sort今ソートされている1列のヘッダーにだけ付けます。未ソート列にaria-sort="none"を全部付ける実装も見ますが、読み上げの意図がぼやけるので、僕は1列に絞る派です。根拠はMDNのaria-sortリファレンスを見ておくと安心です。

スマホで崩さない:横スクロールかカード化か

テーブルのスマホ対応は、ざっくり2択です。

  1. 横スクロールを許す:列の横比較が命の会計表・在庫表は、これが一番正直。下手にカード化すると比較できなくなる。
  2. カード化する:スマホ幅だけ行を縦積みにして、各セルの前にdata-labelでラベルを出す。顧客一覧や記事一覧向き。

下のCSSは2のカード化です。ミソは、DOMはtableのままにして、見た目だけCSSで変えていること。theadは視覚的に隠すけれどDOMには残すので、支援技術には表の構造がそのまま伝わります。

/* src/components/data-table.css */
.table-shell {
  display: grid;
  gap: 1rem;
}

.table-actions {
  display: flex;
  align-items: end;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
}

.table-actions label {
  display: grid;
  gap: 0.35rem;
  min-width: min(100%, 18rem);
}

.table-actions input {
  border: 1px solid #cbd5e1;
  border-radius: 0.5rem;
  padding: 0.55rem 0.75rem;
}

.table-scroll {
  overflow-x: auto;
  border: 1px solid #d8dee8;
  border-radius: 0.5rem;
}

.data-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.95rem;
}

.data-table caption {
  padding: 0.75rem;
  text-align: left;
  font-weight: 600;
}

.data-table th,
.data-table td {
  border-top: 1px solid #e5e7eb;
  padding: 0.75rem;
  text-align: left;
  vertical-align: middle;
}

.data-table th.numeric,
.data-table td.numeric {
  text-align: right;
}

.data-table th button {
  border: 0;
  background: transparent;
  color: inherit;
  cursor: pointer;
  font: inherit;
  font-weight: 700;
  padding: 0;
}

.data-table tbody tr:hover {
  background: #f8fafc;
}

.status {
  border-radius: 999px;
  display: inline-block;
  font-size: 0.8rem;
  padding: 0.2rem 0.55rem;
}

.status-active {
  background: #dcfce7;
  color: #166534;
}

.status-trial {
  background: #e0f2fe;
  color: #075985;
}

.status-paused {
  background: #fef3c7;
  color: #92400e;
}

.pagination {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 0.75rem;
}

.pagination button {
  border: 1px solid #cbd5e1;
  border-radius: 0.45rem;
  background: white;
  padding: 0.45rem 0.75rem;
}

.pagination button:disabled {
  cursor: not-allowed;
  opacity: 0.45;
}

@media (max-width: 640px) {
  .table-scroll {
    overflow: visible;
    border: 0;
  }

  .data-table thead {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
  }

  .data-table,
  .data-table tbody,
  .data-table tr,
  .data-table th,
  .data-table td {
    display: block;
    width: 100%;
  }

  .data-table tr {
    border: 1px solid #d8dee8;
    border-radius: 0.5rem;
    margin-bottom: 0.75rem;
    padding: 0.25rem 0;
  }

  .data-table th,
  .data-table td,
  .data-table th.numeric,
  .data-table td.numeric {
    display: grid;
    grid-template-columns: 8.5rem 1fr;
    gap: 0.75rem;
    text-align: left;
  }

  .data-table th::before,
  .data-table td::before {
    color: #64748b;
    content: attr(data-label);
    font-weight: 700;
  }
}

content: attr(data-label)で、各セルのdata-labelをそのまま擬似要素に出しています。だからJSX側で全セルにdata-labelを渡しておくのが効いてくる。ブレークポイントの決め方そのものはレスポンシブCSS設計の考え方も合わせてどうぞ。

キーボードとa11yで落としがちな所

見た目が整ったテーブルでも、キーボードで触ると粗が出ます。僕がレビューで必ず見るのはこの3点です。

  • ソートはth直クリックではなくbuttonにする。 thに直接onClickを付けるとフォーカスが当たらず、EnterやSpaceで押せません。見出しセルの中に<button>を入れるだけで、キーボード操作とフォーカスリングが効くようになります。
  • captionを省かない。 スクリーンリーダーで表に入った瞬間「何の表か」を伝えるのがcaption。地味だけど効果が大きい。
  • role="grid"を雰囲気で付けない。 スプレッドシートみたいにセル単位で矢印キー移動・編集をするなら別ですが、一覧表示のソート・検索・ページングなら、ネイティブのtableのままが一番ラク。role="grid"を付けると、その瞬間からキーボード操作を自前で全部実装する責任が乗ってきます。

<table>そのものの意味はMDNの<table>リファレンスが一次情報です。

列が増えたらTanStack Tableに寄せる

列の表示切り替え、列リサイズ、行選択、サーバーサイドページング——このあたりが出てきたら、自前のuseState地獄をやめてTanStack Tableに移します。インストールは@tanstack/react-table一つ。状態の作り方はこんな形です。

import {
  type ColumnDef,
  type PaginationState,
  type SortingState,
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
} from "@tanstack/react-table";
import { useState } from "react";
import type { Customer } from "./DataTable";

// 列定義。accessorKey は Customer のキーに型で縛られる
const tanStackColumns: ColumnDef<Customer>[] = [
  { accessorKey: "name", header: "Customer" },
  { accessorKey: "plan", header: "Plan" },
  { accessorKey: "mrr", header: "MRR" },
  { accessorKey: "status", header: "Status" },
  { accessorKey: "signedUpAt", header: "Signed up" },
];

export function useCustomerTable(data: Customer[]) {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [globalFilter, setGlobalFilter] = useState("");
  const [pagination, setPagination] = useState<PaginationState>({
    pageIndex: 0,
    pageSize: 10,
  });

  // 必要な行モデルだけ getXxxRowModel() で有効化する(使わない機能は入れない)
  return useReactTable({
    data,
    columns: tanStackColumns,
    state: { sorting, globalFilter, pagination },
    onSortingChange: setSorting,
    onGlobalFilterChange: setGlobalFilter,
    onPaginationChange: setPagination,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  });
}

返ってきたtableからtable.getHeaderGroups()table.getRowModel().rowsを回せば、さっきと同じ<table>マークアップに流し込めます。状態管理だけ持ち上げて、見た目とa11yは自前のまま——これがヘッドレスのおいしい所です。getSortedRowModelなど、使わない機能の行モデルは渡さなければ動かないので、バンドルも要件分だけで済みます。

何千行を描くときは仮想化

行が数千を超えると、tbodyに全部の<tr>を出した瞬間にスクロールがカクつきます。理由は単純で、DOMノードが多すぎるから。ここで効くのが**仮想化(virtualization)**です。要は「画面に映っている十数行ぶんだけDOMを作って、スクロールに合わせて中身を入れ替える」テクニックです。

考え方だけ最小コードで掴んでおきましょう。スクロール位置から「今どの行を描けばいいか」を計算して、その範囲だけ切り出します。

// 概念デモ:固定行高で「見える範囲」だけ描く最小の仮想リスト
import { useRef, useState } from "react";

const rowHeight = 44; // 1行の高さ(px)。可変高なら計測が要る
const viewportHeight = 440; // スクロール領域の高さ(px)

export function VirtualRows({ rows }: { rows: string[] }) {
  const [scrollTop, setScrollTop] = useState(0);
  const containerRef = useRef<HTMLDivElement>(null);

  const total = rows.length;
  // 画面に入る行+上下のバッファを少し多めに描く
  const start = Math.max(0, Math.floor(scrollTop / rowHeight) - 4);
  const visibleCount = Math.ceil(viewportHeight / rowHeight) + 8;
  const end = Math.min(total, start + visibleCount);
  const slice = rows.slice(start, end);

  return (
    <div
      ref={containerRef}
      onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
      style={{ height: viewportHeight, overflowY: "auto" }}
    >
      {/* 全行ぶんの高さを確保して、スクロールバーの長さを正しく見せる */}
      <div style={{ height: total * rowHeight, position: "relative" }}>
        {slice.map((label, index) => {
          const rowIndex = start + index;
          return (
            <div
              key={rowIndex}
              style={{
                position: "absolute",
                top: rowIndex * rowHeight,
                height: rowHeight,
                lineHeight: `${rowHeight}px`,
              }}
            >
              {label}
            </div>
          );
        })}
      </div>
    </div>
  );
}

これはあくまで仕組みの説明用です。実戦では行高が可変だったり、<table>のセマンティクスを保ったまま仮想化したかったりするので、@tanstack/react-virtualのような専用ライブラリに任せるのが安全です。TanStack Tableと同じ作者なので組み合わせも素直。仮想化は「重くなってから」入れるのが鉄則で、数百行のうちから入れるとデバッグが無駄に難しくなるだけです。

動いてるか目視で済ませない

テーブルのバグは目視だと本当に見逃します。「検索後にページが2ページ目のままで空表示」「ソート矢印だけ変わって中身が並ばない」「スマホで列名が消える」——どれも一見動いてるように見えるやつです。だから僕は操作系をE2Eで縛ります。

// tests/customer-table.spec.ts
import { expect, test } from "@playwright/test";

test("filters, sorts, paginates, and keeps mobile labels", async ({ page }) => {
  await page.goto("/customers");

  await expect(
    page.getByRole("table", { name: /monthly recurring revenue/i }),
  ).toBeVisible();

  // MRR列でソート → その列にだけ aria-sort が付く
  await page.getByRole("button", { name: /MRR/ }).click();
  await expect(page.getByRole("columnheader", { name: /MRR/ })).toHaveAttribute(
    "aria-sort",
    "ascending",
  );

  // 検索 → 1ページ目に戻り、ヒット行が見える
  await page.getByLabel("Filter customers").fill("north");
  await expect(page.getByRole("row", { name: /Northwind/ })).toBeVisible();
  await expect(page.getByText("No customers match this filter.")).toBeHidden();

  // 検索クリア → 次ページへ
  await page.getByLabel("Filter customers").fill("");
  await page.getByRole("button", { name: "Next" }).click();
  await expect(page.getByText("Page 2 of 2")).toBeVisible();

  // スマホ幅で data-label が生きている
  await page.setViewportSize({ width: 390, height: 844 });
  await expect(page.locator("td[data-label='Plan']").first()).toBeVisible();
});

getByRole("columnheader")getByRole("table")でアクセシブルな名前を使ってテストを書くと、a11yがちゃんと効いているかも同時に検証できて一石二鳥です。E2Eが壊れやすくて困っているならPlaywright E2Eのフレーキー潰しに、僕がハマった対処を書いてあります。

よくある質問

Q. ソートとページネーション、どっちを先に計算すべき? A. 必ずフィルタ → ソート → ページ切り出しの順です。ページで切ってからソートすると、そのページ内だけ並び替わって全体の順序が壊れます。上のコードのuseMemo3段がこの順番になっています。

Q. aria-sortは全列に付けるべき? A. 今ソート中の1列にだけで十分です。MDNの定義でも現在ソートされているヘッダーに置く属性なので、未ソート列にnoneを撒くより意図が明確になります。

Q. TanStack Tableを入れたら見た目も付いてくる? A. 付いてきません。ヘッドレスなので、状態(ソート・フィルタ・ページ・選択)だけを管理します。<table>やボタンのデザインは自分のCSSで書きます。逆に言えば既存デザインを壊さないのが利点です。

Q. 仮想化は最初から入れたほうがいい? A. いいえ。数百行ならむしろ不要です。スクロールが体感で重くなってから入れるのが正解。先に入れると、行高の計算やフォーカス移動でデバッグが増えるだけです。

Q. 検索が大量データで重い。どうする? A. まず入力にデバウンス(数百ミリ秒待ってから絞る)を掛けます。それでも重ければ、フィルタとページングをサーバー側に逃がして、フロントは返ってきたページだけ描く構成にします。TanStack TableはmanualPaginationなどサーバー連携前提のオプションを持っています。

実際に試した結果

小さな顧客一覧でこの構成を回してみて、最初に効いたのは「ソート・検索・ページングを1つのコンポーネントの中で閉じる」ことでした。状態が3つのuseMemoにきれいに分かれているおかげで、どこを直せばいいか一瞬で分かる。

逆に一度やらかしたのが、例の「検索したのにページが2ページ目のままで空表示」。setPage(1)を入れ忘れていただけなんですが、目視では“データがない”ように見えて10分悩みました。フィルタ後の表示を確認するPlaywrightテストを足してからは、同じミスが即バレるようになって安心感が違います。

線引きの感覚もはっきりしました。5〜8列・数百行は自前で十分。列設定や行選択が来たらTanStack Tableに寄せる。重くなって初めて仮想化。 この順番で足していけば、最初から大げさなライブラリを抱える必要はありません。テーブルは地味なUIですが、管理画面でも収益レポートでも意思決定の入口になります。React開発全体の進め方はClaude CodeでReact開発を実戦投入する方法に、運用データの並べ方はClaude Codeでアナリティクス実装にまとめてあります。手を動かす教材がほしい人は教材一覧からどうぞ。

#React #TanStack Table #テーブル #ソート #TypeScript
無料

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

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

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

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

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

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

PR

関連書籍・参考図書

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

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