Tips & Tricks (Atualizado: 02/06/2026)

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.

Criar uma tabela React com Claude Code: ordenação, filtro e paginação

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çãoQuando usarAtenção
Componente próprioPoucas colunas, filtro e ordenação simplesCada recurso novo fica com você
TanStack TableEstado complexo, dados de servidor, seleçãoExige aprender a API
Grid empresarialEdição tipo planilha, dados enormesPeso, 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

CasoFunções da tabelaValor
Lista de clientes SaaSPlano, MRR, status, renovaçãoEncontrar churn e upsell
Catálogo ecommerceEstoque, preço, categoria, publicaçãoEvitar erro de venda
Dashboard editorialPV, leitura, cliques CTA, revisãoPriorizar SEO e receita
FaturamentoEstado, valor, vencimentoReduzir 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.

#Claude Code #table #React #TanStack Table #UI
Grátis

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.

Masa

Sobre o autor

Masa

Engenheiro focado em workflows práticos com Claude Code.