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
Como pedir ao Claude Code para mexer em um único arquivo
Do desastre em que um 'deixa melhor' alterou 40 linhas nasceu um template de prompt que limita o escopo, valida e permite reverter.
Recuperar de negações de permissão no Claude Code sem enfraquecer guardrails
Transforme um comando negado em plano seguro com motivo, alternativa, provas e critérios de nova tentativa.
Claude Code Harness Smoke Test: prova de 15 minutos antes de confiar em um agente
Um smoke test para escopo, áreas bloqueadas, comandos de prova, URL pública e CTAs de receita no Claude Code.