Building Headless, Reusable React Table Components for Real-World Dashboards
In a 4āyear-old frontend, data tables tend to accumulate conditional props and flags until they are impossible to reason about. A more maintainable pattern is a headless table component: business logic lives in a hook, while rendering stays completely customizable. The hook owns concerns like sorting, pagination, column visibility, and selection, and exposes a minimal API surface instead of dozens of Boolean props.
This separation improves performance and testability. You can unit test the hook with plain data and stubbed events, while the table UI remains a thin presentational layer. For financial or eācommerce dashboards, it also lets you reuse the same behavior with different designs: dense layouts for ops teams, card-like layouts for customers, or virtualized views for huge datasets. The key is to make the hook opinionated about state transitions but unopinionated about markup so it scales with new requirements instead of fighting them.
š»Source Code
// headless-table/useHeadlessTable.ts
import { useMemo, useState } from "react";
export type Row = Record<string, unknown>;
export type Column<T extends Row> = {
key: keyof T;
label: string;
sortable?: boolean;
width?: number | string;
};
export type SortState<T extends Row> = {
key: keyof T | null;
direction: "asc" | "desc";
};
type Options<T extends Row> = {
rows: T[];
columns: Column<T>[];
initialSort?: SortState<T>;
pageSize?: number;
};
export function useHeadlessTable<T extends Row>({
rows,
columns,
initialSort,
pageSize = 20,
}: Options<T>) {
const [sort, setSort] = useState<SortState<T>>(
initialSort ?? { key: null, direction: "asc" }
);
const [page, setPage] = useState(1);
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
function toggleSort(key: keyof T) {
setSort((prev) => {
if (prev.key !== key) {
return { key, direction: "asc" };
}
return {
key,
direction: prev.direction === "asc" ? "desc" : "asc",
};
});
setPage(1);
}
const sortedRows = useMemo(() => {
if (!sort.key) return rows;
const sorted = [...rows].sort((a, b) => {
const av = a[sort.key!];
const bv = b[sort.key!];
if (av == null && bv == null) return 0;
if (av == null) return -1;
if (bv == null) return 1;
if (av === bv) return 0;
if (typeof av === "number" && typeof bv === "number") {
return av - bv;
}
return String(av).localeCompare(String(bv));
});
if (sort.direction === "desc") sorted.reverse();
return sorted;
}, [rows, sort]);
const paginatedRows = useMemo(() => {
const start = (page - 1) * pageSize;
return sortedRows.slice(start, start + pageSize);
}, [sortedRows, page, pageSize]);
function goToPage(next: number) {
setPage(Math.min(totalPages, Math.max(1, next)));
}
return {
columns,
sort,
page,
totalPages,
rows: paginatedRows,
allRows: sortedRows,
toggleSort,
goToPage,
};
}
// components/TransactionsTable.tsx ā presentational layer
import React from "react";
import { useHeadlessTable, Column } from "./headless-table/useHeadlessTable";
type Transaction = {
id: string;
createdAt: string;
customer: string;
amount: number;
status: "pending" | "completed" | "failed";
};
const columns: Column<Transaction>[] = [
{ key: "createdAt", label: "Date", sortable: true, width: 140 },
{ key: "customer", label: "Customer", sortable: true },
{ key: "amount", label: "Amount", sortable: true, width: 120 },
{ key: "status", label: "Status", sortable: true, width: 120 },
];
export function TransactionsTable({ data }: { data: Transaction[] }) {
const table = useHeadlessTable<Transaction>({
rows: data,
columns,
initialSort: { key: "createdAt", direction: "desc" },
pageSize: 25,
});
return (
<div className="card">
<div className="card-header">
<h2>Transactions</h2>
<p className="subtitle">
{data.length.toLocaleString()} total transactions
</p>
</div>
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
{table.columns.map((col) => {
const isSorted = table.sort.key === col.key;
const direction = table.sort.direction;
return (
<th
key={String(col.key)}
style={{ width: col.width }}
onClick={() =>
col.sortable && table.toggleSort(col.key)
}
className={col.sortable ? "sortable" : undefined}
>
<span>{col.label}</span>
{col.sortable && (
<span className="sort-indicator">
{isSorted
? direction === "asc"
? "ā²"
: "ā¼"
: "āµ"}
</span>
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{table.rows.length === 0 ? (
<tr>
<td colSpan={columns.length} className="empty">
No transactions found
</td>
</tr>
) : (
table.rows.map((row) => (
<tr key={row.id}>
<td>{new Date(row.createdAt).toLocaleString()}</td>
<td>{row.customer}</td>
<td>${row.amount.toFixed(2)}</td>
<td>
<StatusPill status={row.status} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<footer className="table-footer">
<span>
Page {table.page} of {table.totalPages}
</span>
<div className="pager">
<button
onClick={() => table.goToPage(table.page - 1)}
disabled={table.page === 1}
>
Previous
</button>
<button
onClick={() => table.goToPage(table.page + 1)}
disabled={table.page === table.totalPages}
>
Next
</button>
</div>
</footer>
</div>
);
}
function StatusPill({ status }: { status: Transaction["status"] }) {
const color =
status === "completed"
? "success"
: status === "pending"
? "warning"
: "danger";
return <span className={`status-pill ${color}`}>{status}</span>;
}
// Example usage (e.g. in a dashboard page)
// <TransactionsTable data={transactions}>
// Minimal CSS sketch (adapt as needed)
/*
.card {
border-radius: 12px;
border: 1px solid #e5e7eb;
padding: 1rem 1.25rem;
background: #ffffff;
}
.card-header {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
.table-wrapper {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.data-table th,
.data-table td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid #f3f4f6;
}
.data-table th.sortable {
cursor: pointer;
user-select: none;
}
.data-table th .sort-indicator {
margin-left: 0.25rem;
font-size: 0.7rem;
opacity: 0.6;
}
.data-table tbody tr:hover {
background-color: #f9fafb;
}
.empty {
text-align: center;
color: #9ca3af;
}
.table-footer {
margin-top: 0.75rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.pager button {
margin-left: 0.5rem;
}
.status-pill {
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: capitalize;
}
.status-pill.success {
background: #ecfdf3;
color: #166534;
}
.status-pill.warning {
background: #fffbeb;
color: #92400e;
}
.status-pill.danger {
background: #fef2f2;
color: #b91c1c;
}
@media (max-width: 640px) {
.card {
padding: 0.75rem;
}
.data-table th,
.data-table td {
padding: 0.4rem 0.5rem;
}
}
*/šRelated Snippets
Similar code snippets you might find interesting
Using Intersection Observer for Lazy Loading and Scroll-Based Animations
Leveraging Astro Islands for Interactive Content-Heavy Sites
Maintaining a Simple Global State Store in React Without Extra Libraries
š¬Comments (0)
šPlease login to post comments
No comments yet. Be the first to share your thoughts!
ā”Actions
Share this snippet:
š¤About the Author
admin
Active contributor
