Membuat Komponen Tabel React dengan Claude Code: Sort, Filter, Pagination
Bangun tabel React dengan Claude Code: sorting, filtering, pagination, mobile, TanStack Table, dan Playwright.
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.
| Pilihan | Cocok untuk | Catatan |
|---|---|---|
| Custom component | Kolom sedikit, sort dan filter sederhana | Semua fitur baru harus dibuat sendiri |
| TanStack Table | State kompleks, data server, row selection | Perlu memahami API |
| Enterprise grid | Editing seperti spreadsheet, data besar | Cek 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 case | Fitur tabel | Nilai bisnis |
|---|---|---|
| Daftar pelanggan SaaS | Plan, MRR, status, renewal date | Menemukan risiko churn dan upsell |
| Katalog ecommerce | Stok, harga, kategori, status publish | Mencegah salah harga dan stok kosong |
| Dashboard konten | PV, read rate, CTA click, update date | Menentukan prioritas rewrite dan iklan |
| Histori tagihan | Status, jumlah, due date | Mengurangi 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.
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.
Tentang penulis
Masa
Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.
Artikel terkait
Permission safety ladder Claude Code: perluas akses tanpa kehilangan kontrol
Naik dari read-only ke edit terbatas, command bukti, dan cek deploy dengan kontrol yang jelas.
Claude Code Small PR Proof Pack: perubahan kecil yang mudah direview
Paket bukti untuk PR Claude Code: diff, check, URL publik, jalur CTA, dan rollback.
Review gate Claude Code sebelum commit: diff, test, URL publik, dan CTA
Cara memakai Claude Code sebelum commit: diff scope, build, URL publik, link Gumroad, CTA konsultasi, missing test, dan file tidak terkait.