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

Crear una tabla React con Claude Code: ordenar, filtrar y paginar

Crea una tabla React con Claude Code: ordenación, filtros, paginación, móvil, TanStack Table y Playwright.

Crear una tabla React con Claude Code: ordenar, filtrar y paginar

Empieza por el contrato de la tabla

Una tabla parece un componente sencillo hasta que aparece el uso real: ordenar clientes por ingresos, filtrar por estado, dividir cientos de filas en páginas, leer la información en móvil, operar con teclado y comprobar que nada se rompe después de un rediseño. En paneles de administración, catálogos, facturación, CRM y analítica de contenidos, la tabla no es decoración; es una superficie de decisión.

Claude Code ayuda mucho en este tipo de trabajo, pero necesita restricciones claras. Si solo pides “haz una tabla bonita”, puede generar una cuadrícula de div, olvidar caption, poner un icono de ordenación sin cambiar los datos, o crear una versión móvil que es imposible de leer. El objetivo es pedir una tabla con semántica, estado, comportamiento responsive, accesibilidad y pruebas.

En esta guía construiremos un componente React/TypeScript listo para copiar. Incluye table semántica, columnas ordenables, filtro global, paginación, CSS móvil, revisión de accesibilidad, alternativa con TanStack Table y pruebas con Playwright. Para el flujo React completo, revisa desarrollo React con Claude Code. Para accesibilidad, acompáñalo con accesibilidad con Claude Code.

Las fuentes oficiales son el punto de partida: MDN <table>, MDN aria-sort, TanStack Table, Playwright Writing tests y Claude Code overview.

Base semántica de una tabla

Usa una tabla cuando la relación entre filas y columnas importa. Un cliente con nombre, plan, MRR, estado y fecha de alta necesita sus encabezados para tener sentido. Si solo tienes tarjetas independientes, una lista puede ser mejor. Si el usuario compara valores por columnas, usa table.

La estructura mínima es caption, thead, tbody y th scope="col". Si la primera celda identifica la fila, usa th scope="row". Esto no cambia por sí solo el diseño, pero sí hace que el navegador y las tecnologías de asistencia entiendan la relación de los datos.

<table>
  <caption>Ingresos recurrentes mensuales por cliente</caption>
  <thead>
    <tr>
      <th scope="col">Cliente</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>

Cuando pidas esto a Claude Code, no digas solo “crea una tabla”. Especifica que debe conservar la estructura nativa, incluir caption, usar scope y no cambiar a un grid visual sin semántica.

flowchart TD
  A["Requisitos"] --> B["Tabla semántica"]
  B --> C["Orden, filtro y paginación"]
  C --> D["Móvil"]
  D --> E["Accesibilidad"]
  E --> F["Playwright"]

Prompt recomendado para Claude Code

Un buen prompt evita cambios innecesarios y deja claro cómo se validará el resultado.

Crea una tabla de clientes con React + TypeScript.

Condiciones:
- Modifica solo src/components/DataTable.tsx y src/components/data-table.css
- Usa table, caption, thead, tbody y th scope
- Los campos son id, name, plan, mrr, status, signedUpAt
- Añade filtro global, ordenación por columnas y paginación de 5 filas
- Añade aria-sort solo a la columna ordenada
- Usa button dentro del encabezado para ordenar
- En móvil muestra etiquetas con data-label
- Añade una prueba Playwright para filtro, orden, paginación y móvil

No hagas:
- Añadir una librería UI nueva
- Usar role="grid" sin implementar el modelo de teclado
- Responder con pseudocódigo

La parte más importante es pedir código ejecutable y pruebas. En tablas es común recibir un ejemplo que parece completo, pero donde la paginación está comentada o el botón de orden solo cambia un icono.

Implementación React/TypeScript copiable

Este ejemplo no depende de una librería de tablas. Sirve para listas pequeñas y medianas. Si usas Next.js, "use client"; es correcto; en Vite también puede quedarse sin romper nada.

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

El detalle clave está en updateQuery: al cambiar el filtro, la tabla vuelve a la página 1. Sin esto, una búsqueda válida puede parecer vacía si la persona estaba en una página posterior.

CSS móvil y revisión accesible

El CSS conserva la tabla en el DOM y cambia solo la presentación en pantallas estrechas. Cada celda recibe data-label, así que el móvil puede mostrar el nombre de la columna junto al valor.

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

En la revisión de accesibilidad, comprueba que el caption describa la tabla, que los encabezados sean th, que solo la columna ordenada tenga aria-sort, que la ordenación use button, que el campo de filtro tenga etiqueta y que el cambio de página se comunique con aria-live. Evita role="grid" salvo que implementes el patrón de teclado de una grid real.

Cuándo usar TanStack Table

La implementación propia basta para una lista pequeña. TanStack Table empieza a compensar cuando necesitas visibilidad de columnas, filtros por columna, selección de filas, paginación de servidor, columnas fijas o virtualización. Es una biblioteca headless: gestiona el estado, pero tú sigues controlando el HTML y los estilos.

OpciónCuándo usarlaRiesgo
Componente propioPocas columnas, filtro y orden simpleCada nueva función la mantienes tú
TanStack TableEstado complejo, servidor, selecciónHay que aprender su API
Grid empresarialEdición tipo hoja de cálculoMás peso, configuración y licencias

Pide a Claude Code que justifique la dependencia antes de añadirla. Una tabla de ajustes no necesita la misma arquitectura que un CRM que seguirá creciendo.

Pruebas Playwright

La prueba debe cubrir el flujo, no solo que la página cargue. Aquí se comprueba tabla visible, orden, filtro, paginación y etiquetas en móvil.

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

Cuando Claude Code modifique la tabla, entrégale esta prueba y pide que reproduzca el fallo antes de corregirlo. Así evitas arreglos visuales que rompen el comportamiento.

Usos, trampas y CTA de monetización

CasoFunciones útilesImpacto
Lista de clientes SaaSPlan, MRR, estado, renovaciónDetectar churn y oportunidades de upsell
Catálogo ecommerceStock, precio, categoría, publicaciónEncontrar errores antes de vender
Dashboard editorialPV, lectura, clics CTA, última revisiónPriorizar reescrituras y anuncios
FacturaciónEstado, importe, vencimientoReducir soporte manual

Las trampas habituales son crear la tabla con div, mostrar solo un icono de orden, no volver a página 1 tras filtrar, depender solo del scroll horizontal en móvil y permitir que Claude Code añada una librería nueva sin necesidad. Escribe estas restricciones en el prompt.

Una tabla también ayuda a monetizar cuando hace visible la siguiente acción. En un sitio de contenidos, une tráfico, clics CTA e ingresos por artículo. En SaaS, une MRR, caída de uso y fecha de renovación. Para diseñar este flujo en equipo, revisa formación y consultoría de Claude Code. Para aprender por tu cuenta, empieza por productos y plantillas.

Resultado probado

Masa probó esta estructura en una lista pequeña de clientes. El mayor beneficio fue reiniciar la paginación al filtrar; antes, una búsqueda podía tener resultados pero mostrar una página vacía. El segundo beneficio fue añadir data-label desde el principio, no al final del CSS móvil. Pedir a Claude Code semántica, estado, responsive, accesibilidad y Playwright en una misma tarea produjo un componente más fácil de revisar.

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

PDF gratis: cheatsheet de Claude Code

Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.

Cuidamos tus datos y no enviamos spam.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.