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

Membuat Komponen Tabel React dengan Claude Code: Sort, Filter, Pagination

Bangun tabel React dengan Claude Code: sorting, filtering, pagination, mobile, TanStack Table, dan Playwright.

Membuat Komponen Tabel React dengan Claude Code: Sort, Filter, Pagination

Mulai dari kontrak tabel, bukan tampilan

Tabel muncul di banyak aplikasi: dashboard admin, daftar pelanggan, histori tagihan, katalog produk, laporan internal, dan dashboard konten. Versi pertama biasanya hanya menampilkan baris data. Setelah dipakai, kebutuhan langsung bertambah: sort berdasarkan revenue, filter berdasarkan status, pagination untuk data panjang, tampilan mobile yang masih jelas, navigasi keyboard, dan pengujian otomatis setelah ada perubahan.

Claude Code dapat mempercepat pekerjaan ini, tetapi prompt harus jelas. Jika hanya meminta “buat tabel yang bagus”, hasilnya bisa berupa grid div, tanpa caption, tanpa hubungan jelas antara header dan cell, atau sort icon yang berubah tetapi data tidak benar-benar terurut. Tabel adalah UI untuk memahami hubungan data, jadi minta semantic HTML, state, responsive behavior, accessibility, dan test sekaligus.

Artikel ini membuat komponen React/TypeScript yang bisa disalin. Isinya mencakup semantic table, sortable columns, global filter, pagination, mobile CSS, accessibility review, kapan memakai TanStack Table, dan Playwright checks. Untuk alur React yang lebih luas, baca pengembangan React dengan Claude Code. Untuk aksesibilitas, lihat juga accessibility dengan Claude Code.

Rujukan resmi yang dipakai: MDN <table>, MDN aria-sort, TanStack Table docs, Playwright Writing tests, dan Claude Code overview.

Dasar semantic table

Gunakan table ketika baris dan kolom bersama-sama memberi makna. Nama pelanggan, plan, MRR, status, dan tanggal daftar adalah data tabular karena setiap nilai dipahami lewat header kolomnya. Kalau item berdiri sendiri seperti kartu, list atau card grid bisa lebih tepat.

Struktur minimal adalah caption, thead, tbody, dan th scope="col". Jika cell pertama menjadi identitas baris, gunakan th scope="row".

<table>
  <caption>Monthly recurring revenue per pelanggan</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>

Saat meminta Claude Code, tulis bahwa struktur native table harus dipertahankan. Ini membuat hasil lebih mudah dibaca oleh browser, screen reader, dan test berbasis role.

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

Prompt untuk Claude Code

Prompt yang baik membatasi file yang boleh diubah, menjelaskan data, dan menyebut cara verifikasi.

Buat komponen tabel pelanggan dengan React + TypeScript.

Syarat:
- Ubah hanya src/components/DataTable.tsx dan src/components/data-table.css
- Gunakan table, caption, thead, tbody, dan th scope
- Field data: id, name, plan, mrr, status, signedUpAt
- Tambahkan global filter, column sorting, dan pagination 5 baris
- Pasang aria-sort hanya pada kolom yang sedang di-sort
- Gunakan button di dalam header untuk sorting
- Di mobile, tampilkan label cell dengan data-label
- Tambahkan Playwright test untuk filter, sort, pagination, dan mobile label

Jangan:
- Menambahkan UI library baru
- Menggunakan role="grid" tanpa keyboard model yang sesuai
- Menghasilkan pseudocode

Dengan batasan ini, Claude Code diarahkan untuk membuat implementasi yang bisa direview, bukan sekadar tampilan sementara.

Implementasi React/TypeScript siap salin

Contoh berikut tidak memakai library tabel. Cocok untuk daftar kecil sampai menengah.

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

Hal kecil yang paling penting adalah setPage(1) saat filter berubah. Tanpa itu, pengguna di halaman 2 bisa melihat hasil kosong walaupun data sebenarnya ada di halaman 1.

CSS mobile dan accessibility

CSS berikut mempertahankan struktur tabel di DOM, tetapi menumpuk baris saat layar sempit.

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

Review accessibility harus mengecek caption, scope, sort button, aria-sort, label filter, dan aria-live pada pagination. Jangan memakai role="grid" kecuali benar-benar membuat perilaku grid dengan keyboard.

Kapan memakai TanStack Table

Komponen custom cukup untuk list kecil. TanStack Table lebih cocok ketika ada column visibility, filter per kolom, row selection, server-side pagination, pinned columns, atau virtualization. Library ini headless, jadi ia mengurus logic tabel sementara UI tetap mengikuti desain proyek.

PilihanCocok untukCatatan
Custom componentKolom sedikit, sort dan filter sederhanaSemua fitur baru harus dibuat sendiri
TanStack TableState kompleks, data server, row selectionPerlu memahami API
Enterprise gridEditing seperti spreadsheet, data besarCek ukuran, konfigurasi, dan lisensi

Minta Claude Code menjelaskan alasan dependency sebelum menambahkannya. Tidak semua tabel perlu grid besar.

Playwright checks

Test harus mengikuti workflow pengguna: melihat tabel, sort, filter, pindah halaman, dan membaca label di 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();
});

Saat meminta perbaikan, berikan test ini ke Claude Code dan minta ia mereproduksi kegagalan sebelum mengubah kode.

Use case, jebakan, dan CTA monetisasi

Use caseFitur tabelNilai bisnis
Daftar pelanggan SaaSPlan, MRR, status, renewal dateMenemukan risiko churn dan upsell
Katalog ecommerceStok, harga, kategori, status publishMencegah salah harga dan stok kosong
Dashboard kontenPV, read rate, CTA click, update dateMenentukan prioritas rewrite dan iklan
Histori tagihanStatus, jumlah, due dateMengurangi pekerjaan support

Jebakan yang sering terjadi: membuat tabel palsu dengan div, hanya mengubah ikon sort, lupa kembali ke halaman 1 setelah filter, mobile dikerjakan paling akhir, dan membiarkan Claude Code memasang UI library baru tanpa kebutuhan.

Tabel juga membantu monetisasi karena membuat tindakan berikutnya terlihat. Situs konten bisa menyandingkan traffic, CTA click, dan pendapatan per artikel. SaaS bisa menyandingkan MRR, penurunan penggunaan, dan tanggal renewal. Untuk alur tim, lihat training dan konsultasi Claude Code. Untuk belajar sendiri, mulai dari produk dan template.

Hasil yang sudah dicoba

Masa mencoba struktur ini pada daftar pelanggan kecil. Perbaikan paling terasa adalah mengembalikan pagination ke halaman 1 ketika filter berubah; sebelumnya pencarian bisa terlihat kosong jika pengguna berada di halaman 2. Perbaikan kedua adalah menambahkan data-label sejak awal, sehingga CSS mobile tidak perlu ditambal belakangan. Meminta Claude Code menangani semantik, state, mobile, accessibility, dan Playwright dalam satu tugas membuat hasil lebih mudah direview.

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

PDF gratis: cheatsheet Claude Code

Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.

Kami menjaga datamu dan tidak mengirim spam.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.