Tips & Tricks (Updated: 6/2/2026)

Build a React Table Component with Claude Code: Sort, Filter, Pagination

Build a React table with Claude Code: sorting, filtering, pagination, mobile CSS, TanStack Table, and Playwright.

Build a React Table Component with Claude Code: Sort, Filter, Pagination

Start with the table contract, not the pixels

Tables show up everywhere: customer lists, billing history, admin dashboards, product catalogs, article analytics, CSV previews, and internal reports. The first version often starts as “render these rows,” but the real work arrives quickly. Someone asks for sorting by revenue, filtering by status, pagination for long lists, mobile layout, keyboard operation, and a quick way to prove the component still works after a design change.

Claude Code can move this work fast, but only if the prompt describes the table as a product surface, not just a block of markup. A weak prompt such as “make a nice table” can produce a visual grid that ignores semantic HTML, loses the current page after filtering, or shows a sort arrow without telling assistive technology which column is sorted. A strong prompt names the data type, table semantics, state transitions, responsive behavior, accessibility rules, and verification commands.

This guide builds a copy-pasteable React and TypeScript table component with sortable columns, global filtering, pagination, responsive/mobile handling, accessibility details, a TanStack Table upgrade path, and Playwright checks. For the broader React workflow, pair it with React development with Claude Code. For deeper accessibility review, see accessibility with Claude Code. For E2E coverage, use it with Playwright testing with Claude Code.

Use official sources when checking details. The baseline table semantics are in MDN’s <table> reference. Sort announcements are covered by MDN aria-sort. More complex table state is documented in TanStack Table. Interaction tests are covered in Playwright Writing tests. Claude Code workflow details start from the Claude Code overview.

Semantic table basics

Use a table when rows and columns create meaning together. A customer row with customer name, plan, monthly recurring revenue, status, and signup date is tabular data because every value is understood through its column header. If the layout is just a set of independent cards, a list or card grid may be better. If the user needs to compare values across columns, keep the native table.

The minimum structure is table, caption, thead, tbody, and th scope="col". The caption tells the purpose of the table. Column headers use th with scope="col". If the first cell in each row identifies the row, use th scope="row" for that cell. This gives browsers and assistive technology a clear relationship between the row label and the data cells.

<table>
  <caption>Monthly recurring revenue by customer</caption>
  <thead>
    <tr>
      <th scope="col">Customer</th>
      <th scope="col">Plan</th>
      <th scope="col">MRR</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Northwind</th>
      <td>Pro</td>
      <td>$1,200</td>
    </tr>
  </tbody>
</table>

This is the part beginners often skip because the visual result can look identical with divs. The difference appears when someone navigates with a screen reader, tests keyboard operation, exports the data, or asks why sorting is confusing. Claude Code should be told to preserve the semantic table structure from the first prompt.

flowchart TD
  A["Requirements"] --> B["Semantic table"]
  B --> C["Sort, filter, pagination state"]
  C --> D["Mobile behavior"]
  D --> E["Accessibility review"]
  E --> F["Playwright checks"]

Prompt Claude Code with constraints

The best prompt names what can change and what must not change. For a production app, I usually include the target files, data shape, allowed dependencies, accessibility rules, and verification commands. That prevents Claude Code from solving a small table problem by adding an entire UI kit or replacing an existing design system.

Build a React + TypeScript customer table component.

Scope:
- Only add or update src/components/DataTable.tsx and src/components/data-table.css
- Use native table, caption, thead, tbody, and th scope attributes
- Customer fields: id, name, plan, mrr, status, signedUpAt
- Add global filtering, column sorting, and 5-row pagination
- Put aria-sort only on the currently sorted column
- Use buttons inside column headers for sorting
- On mobile, expose cell labels with data-label instead of relying only on horizontal scroll
- Add a Playwright test for filtering, sorting, pagination, and mobile labels

Do not:
- Add a new UI library
- Use role="grid" unless you also implement the required grid keyboard model
- Return pseudocode

The last line matters. A table looks simple enough that pseudo-implementations sneak in easily: comments where pagination should be, sort handlers that only toggle an icon, or tests that assert the page loaded but never sort or filter. Ask for runnable code and concrete checks.

Copy-paste React and TypeScript implementation

The following component is intentionally dependency-light. It works in a client-side React app and in a Next.js client component. If you are not using Next.js, the "use client"; directive is harmless, though you can remove it.

// 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"),
  },
];

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",
  });

  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]);

  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]);

  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]);

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

The important behavior is not the arrow icon. The important behavior is that sorting changes the actual row order, the sorted header receives aria-sort, filtering resets the page to 1, and pagination never shows a blank page when matching rows exist.

Responsive CSS for mobile tables

There is no single correct mobile table pattern. If the user must compare many columns, horizontal scrolling can be honest and useful. If the table is an operational list where each row can stand on its own, a stacked mobile layout is often easier. The CSS below keeps the semantic table in the DOM and only changes the visual presentation under 640px.

/* 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;
  }
}

Notice that the component sets data-label on every body cell. That is not just a styling trick. It keeps the mobile layout predictable because the CSS never has to guess which column a cell belongs to.

Accessibility review points

For this kind of table, avoid jumping straight to ARIA-heavy patterns. Native tables already carry useful semantics. Add ARIA only where state needs to be communicated, such as the current sort direction. The sort action should be a real button inside the header cell, not a clickable header with no keyboard affordance.

Review the component with this checklist:

- Does the caption describe what the rows represent?
- Are column headers th elements with scope="col"?
- Is the row label a th with scope="row" when appropriate?
- Is aria-sort present only on the sorted column?
- Can the user sort with keyboard focus on a button?
- Does the filter input have an accessible label?
- Does pagination announce the current page with aria-live?
- Is role="grid" avoided unless grid keyboard behavior is implemented?

The role="grid" warning is practical. A grid implies a richer interaction model. If the component is only a sortable and filterable data table, the native table is easier to implement correctly and easier for users to understand.

When to choose TanStack Table

The dependency-free component is enough for many admin lists. Move to TanStack Table when state grows beyond simple sorting and filtering: column visibility, per-column filters, row selection, pinned columns, server-side pagination, grouping, or virtualization. TanStack Table is headless, so it manages table logic while you keep control over markup and styles.

OptionBest fitTradeoff
Custom componentSmall list, a few columns, simple sort/filterYou own every new feature
TanStack TableComplex columns, server state, selection, paginationYou must learn the API and build the UI
Enterprise gridSpreadsheet-like editing and very large dataHigher setup, bundle, and licensing concerns
import {
  type ColumnDef,
  type PaginationState,
  type SortingState,
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
} from "@tanstack/react-table";
import { useState } from "react";

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

  return useReactTable({
    data,
    columns: tanStackColumns,
    state: { sorting, globalFilter, pagination },
    onSortingChange: setSorting,
    onGlobalFilterChange: setGlobalFilter,
    onPaginationChange: setPagination,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  });
}

Ask Claude Code to justify the dependency before adding it. A table used on one settings page does not need the same architecture as a CRM-style grid that will keep gaining features.

Playwright checks

The table should be tested as a workflow, not as static markup. Check that a user can see the table, sort a column, filter rows, move to the next page, and still understand cells at a mobile viewport. Playwright’s locator style works well here because the test can target roles and labels instead of fragile CSS selectors.

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

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

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

  await page.getByRole("button", { name: /MRR/ }).click();
  await expect(page.getByRole("columnheader", { name: /MRR/ })).toHaveAttribute(
    "aria-sort",
    "ascending",
  );

  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();

  await page.setViewportSize({ width: 390, height: 844 });
  await expect(page.locator("td[data-label='Plan']").first()).toBeVisible();
});

This catches the failures I see most often: a sort button that does not update state, filtering that leaves the user on an empty page, and mobile CSS that hides the column context.

Practical use cases and pitfalls

Use caseTable featuresBusiness value
SaaS customer listPlan, MRR, status, renewal dateFind churn risk and upsell candidates
Ecommerce catalogStock, price, publish state, categoryCatch out-of-stock and pricing issues faster
Content dashboardTraffic, read rate, CTA clicks, last updatePrioritize rewrites and monetization work
Billing historyPayment state, amount, due date, customerReduce support time on invoices

The common pitfall is treating the table as a visual component only. It is actually a decision surface. If the row should trigger an action, design that action. If revenue depends on the table, track the click or update. For article operations, combine this with analytics implementation with Claude Code so CTA clicks and rewrite priority are visible beside the content list.

Other pitfalls are mechanical: using divs for tabular data, putting aria-sort on every column, forgetting to reset pagination after filtering, relying only on horizontal scrolling on narrow screens, or letting Claude Code add a UI library that the project does not already use. Put these constraints in the prompt and ask for a final accessibility review.

Monetization note and tested result

Tables support monetization when they make the next action obvious. In a media site, a content table with page views, read completion, CTA clicks, and revenue per article can guide rewrites. In a SaaS product, a customer table with MRR, usage drop, and renewal date can guide customer success outreach. The table itself does not make money, but it makes the work that improves revenue visible.

If your team wants a repeatable Claude Code workflow for dashboards and internal tools, see Claude Code training and consulting. If you are learning solo, the products and templates page collects prompts and implementation checklists.

Masa tested this structure on a small customer list. The biggest practical fix was resetting pagination when the filter changed; without it, filtered results could exist while page 2 looked empty. The second useful fix was adding mobile data-label values from the beginning instead of patching mobile CSS later. Asking Claude Code for semantics, state, mobile behavior, accessibility, and Playwright checks in one task produced a table that was easier to review and harder to break.

#Claude Code #table #React #TanStack Table #UI
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.