Criar uma tabela React com Claude Code: ordenação, filtro e paginação
Crie uma tabela React com Claude Code: ordenação, filtros, paginação, mobile, TanStack Table e Playwright.
Defina o contrato da tabela antes do visual
Tabelas aparecem em painéis administrativos, CRM, faturamento, catálogos, relatórios e dashboards editoriais. A primeira versão costuma apenas listar dados, mas o uso real pede mais: ordenar por receita, filtrar por status, dividir em páginas, funcionar no celular, aceitar teclado e ter testes que confirmem o comportamento depois de uma mudança.
Claude Code acelera esse trabalho, desde que o pedido seja específico. “Faça uma tabela bonita” pode virar uma grade de div, sem caption, sem relação clara entre cabeçalho e célula, com ícone de ordenação que não muda a ordem real. Uma boa instrução define semântica HTML, estado, responsividade, acessibilidade e comandos de validação.
Neste guia vamos criar um componente React/TypeScript copiável. Ele cobre table semântico, colunas ordenáveis, filtro global, paginação, mobile, acessibilidade, quando usar TanStack Table e como verificar com Playwright. Para o fluxo React completo, veja desenvolvimento React com Claude Code. Para acessibilidade, leia também acessibilidade com Claude Code.
As fontes oficiais usadas como base são MDN <table>, MDN aria-sort, TanStack Table, Playwright Writing tests e Claude Code overview.
Base semântica da tabela
Use table quando linhas e colunas explicam os dados juntas. Cliente, plano, MRR, status e data de cadastro são valores tabulares porque cada célula depende do cabeçalho. Se o conteúdo for apenas uma sequência de cards independentes, uma lista pode ser melhor.
O mínimo é caption, thead, tbody e th scope="col". Se a primeira célula identifica a linha, use th scope="row". Isso ajuda navegador, leitores de tela e testes baseados em roles.
<table>
<caption>Receita recorrente mensal por cliente</caption>
<thead>
<tr>
<th scope="col">Cliente</th>
<th scope="col">Plano</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>
No prompt para Claude Code, escreva explicitamente que a estrutura nativa deve ser mantida. Isso evita uma solução visualmente parecida, mas frágil para acessibilidade.
flowchart TD
A["Requisitos"] --> B["Tabela semântica"]
B --> C["Ordenação, filtro e paginação"]
C --> D["Mobile"]
D --> E["Acessibilidade"]
E --> F["Playwright"]
Prompt para Claude Code
Um bom prompt reduz mudanças fora de escopo e deixa a revisão objetiva.
Crie uma tabela de clientes com React + TypeScript.
Condições:
- Altere apenas src/components/DataTable.tsx e src/components/data-table.css
- Use table, caption, thead, tbody e th scope
- Campos: id, name, plan, mrr, status, signedUpAt
- Adicione filtro global, ordenação por coluna e paginação de 5 linhas
- Use aria-sort apenas na coluna ordenada
- Use button dentro do cabeçalho para ordenar
- No mobile, mostre os rótulos com data-label
- Adicione teste Playwright para filtro, ordenação, paginação e mobile
Não faça:
- Adicionar biblioteca UI nova
- Usar role="grid" sem implementar o modelo de teclado
- Entregar pseudocódigo
Esse nível de detalhe transforma Claude Code em um assistente de implementação revisável, não em um gerador de markup solto.
Implementação React/TypeScript copiável
O exemplo abaixo não usa biblioteca de tabela. É suficiente para listas pequenas e médias.
// 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;
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 rows: 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 money = 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) => money.format(row.mrr) },
{ key: "status", label: "Status" },
{ key: "signedUpAt", label: "Signed up", render: (_, row) => new Date(row.signedUpAt).toLocaleDateString("en-US") },
];
function compare<T>(a: T, b: T, key: keyof T) {
const left = a[key];
const right = b[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() {
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [sort, setSort] = useState<SortState<Customer>>({ key: "name", direction: "asc" });
const filtered = 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]);
const sorted = useMemo(() => {
if (!sort) return filtered;
return [...filtered].sort((a, b) => {
const result = compare(a, b, sort.key);
return sort.direction === "asc" ? result : -result;
});
}, [filtered, sort]);
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const currentPage = Math.min(page, totalPages);
const pageRows = sorted.slice((currentPage - 1) * pageSize, currentPage * pageSize);
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-title">
<label>
<span>Filter customers</span>
<input value={query} onChange={(event) => updateQuery(event.target.value)} type="search" />
</label>
<div className="table-scroll" tabIndex={0}>
<table className="data-table">
<caption id="customers-title">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}</button>
</th>
);
})}
</tr>
</thead>
<tbody>
{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]);
return index === 0 ? (
<th key={String(column.key)} scope="row" data-label={column.label}>{content}</th>
) : (
<td key={String(column.key)} data-label={column.label} className={column.numeric ? "numeric" : undefined}>{content}</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<nav className="pagination" aria-label="Table pagination">
<button type="button" disabled={currentPage === 1} onClick={() => setPage((value) => value - 1)}>Previous</button>
<span aria-live="polite">Page {currentPage} of {totalPages}</span>
<button type="button" disabled={currentPage === totalPages} onClick={() => setPage((value) => value + 1)}>Next</button>
</nav>
</section>
);
}
O detalhe mais importante é voltar para a página 1 quando o filtro muda. Sem isso, a pessoa pode estar na página 2, filtrar por um termo com resultado e ver uma tela vazia.
CSS mobile e acessibilidade
Este CSS mantém a tabela no DOM e muda apenas a apresentação em telas estreitas.
.table-scroll {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
border-top: 1px solid #e5e7eb;
padding: 0.75rem;
text-align: left;
}
.data-table .numeric {
text-align: right;
}
.pagination {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
@media (max-width: 640px) {
.data-table thead {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.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;
}
.data-table th,
.data-table td {
display: grid;
grid-template-columns: 8rem 1fr;
gap: 0.75rem;
}
.data-table th::before,
.data-table td::before {
content: attr(data-label);
font-weight: 700;
}
}
Na revisão, confirme caption, scope, botão de ordenação, aria-sort, rótulo do filtro e aria-live na paginação. Evite role="grid" se você não implementou navegação de grid com teclado.
Quando usar TanStack Table
Para listas simples, o componente próprio é suficiente. TanStack Table passa a valer quando há visibilidade de colunas, filtros por coluna, seleção de linhas, paginação no servidor, colunas fixas ou virtualização. Ele é headless: cuida da lógica, mas você mantém HTML e CSS.
| Opção | Quando usar | Atenção |
|---|---|---|
| Componente próprio | Poucas colunas, filtro e ordenação simples | Cada recurso novo fica com você |
| TanStack Table | Estado complexo, dados de servidor, seleção | Exige aprender a API |
| Grid empresarial | Edição tipo planilha, dados enormes | Peso, configuração e licença |
Peça a Claude Code que justifique a dependência antes de adicioná-la. Uma tabela de configuração não precisa da mesma arquitetura de um CRM.
Testes Playwright
Teste o fluxo de uso: tabela visível, ordenação, filtro, paginação e rótulos no mobile.
// tests/customer-table.spec.ts
import { expect, test } from "@playwright/test";
test("customer table works", 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 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();
});
Ao pedir uma correção, entregue esse teste para Claude Code e peça para reproduzir a falha antes de alterar o componente.
Casos de uso, armadilhas e CTA
| Caso | Funções da tabela | Valor |
|---|---|---|
| Lista de clientes SaaS | Plano, MRR, status, renovação | Encontrar churn e upsell |
| Catálogo ecommerce | Estoque, preço, categoria, publicação | Evitar erro de venda |
| Dashboard editorial | PV, leitura, cliques CTA, revisão | Priorizar SEO e receita |
| Faturamento | Estado, valor, vencimento | Reduzir suporte manual |
As armadilhas comuns são criar falsa tabela com div, mostrar seta de ordenação sem estado real, não voltar para página 1 após filtrar, tratar mobile só no fim e deixar Claude Code instalar biblioteca desnecessária. Coloque essas regras no prompt.
Tabela também ajuda monetização quando deixa clara a próxima ação. Um site de conteúdo pode juntar tráfego, cliques CTA e receita por artigo. Um SaaS pode juntar MRR, queda de uso e renovação. Para criar esse fluxo em equipe, veja treinamento e consultoria Claude Code. Para estudar sozinho, comece por produtos e templates.
Resultado testado
Masa testou essa estrutura em uma lista pequena de clientes. O ganho mais visível foi voltar à página 1 após filtrar; antes, uma busca podia parecer vazia. O segundo ganho foi adicionar data-label desde o começo, em vez de remendar o CSS mobile depois. Pedir semântica, estado, mobile, acessibilidade e Playwright na mesma tarefa deixou o componente mais fácil de revisar.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Escada de segurança de permissões no Claude Code
Amplie de read-only para edições limitadas, comandos de prova e deploy checks sem perder controle.
Claude Code Small PR Proof Pack: pequenas mudanças fáceis de revisar
Um pacote de prova para PRs do Claude Code: diff, checks, URL pública, CTA e rollback.
Gate de revisão antes do commit com Claude Code
Revisão antes do commit com Claude Code: diff, build, URL pública, Gumroad, consultoria, testes e arquivos fora do escopo.