Sortable Table in React: Column Sort, Filter, Pagination
Build a fully sortable React table with column sorting, live search filtering, and pagination — no heavy libraries needed. Real code, real patterns, zero fluff.
Why Most Table Libraries Are Overkill
Honestly, the first instinct when you need a sortable table is to reach for TanStack Table or AG Grid. Both are excellent. But they're also enormous dependencies for what is, at its core, a sorted array rendered in some <tr> tags.
If your table has fewer than 10,000 rows and you don't need virtualisation, you can probably build exactly what you need in 150 lines of TypeScript. No new npm package. No peer dependency conflicts. No fighting against someone else's CSS-in-JS opinions.
This article walks through building a table component from scratch — column sort, text filter, page-based pagination — using plain React hooks and Tailwind v4.0.2. You'll end up with something you actually understand and can extend without reading a changelog.
The Data Shape and Column Config
First, define a generic column config type. This is the part most tutorials skip. Without a clean column definition, you end up hardcoding header labels and accessor keys in three different places and then debugging a typo at 11pm.
Here's the type we'll use throughout:
``tsx
export type ColumnDef<T> = {
key: keyof T;
label: string;
sortable?: boolean;
render?: (value: T[keyof T], row: T) => React.ReactNode;
};
`
The optional render` callback is what separates a useful table from a read-only one. Pass it a badge component, a status pill, an action button — whatever the row needs.
With this type in place, you declare your columns once and the table component handles everything from there. Change a label? One line. Add a computed column? Add a render callback. It stays honest.
Implementing Column Sort with useMemo
Sorting state is two values: which column key and which direction ('asc' | 'desc' | null). Don't over-engineer this. Two useState calls work fine.
The sort logic itself lives in a useMemo that depends on those two values plus the raw data array. This avoids re-sorting on every keystroke when the user is typing in the filter input — you'll thank yourself for this later.
``tsx
const sortedData = useMemo(() => {
if (!sortKey || sortDir === null) return data;
return [...data].sort((a, b) => {
const aVal = a[sortKey];
const bVal = b[sortKey];
if (aVal === bVal) return 0;
const result = aVal < bVal ? -1 : 1;
return sortDir === 'asc' ? result : -result;
});
}, [data, sortKey, sortDir]);
`
Note the [...data]` spread. Always sort a copy. Mutating props is the kind of bug that produces a two-hour debugging session involving React.StrictMode and a very confused colleague.
Clicking a column header cycles through asc → desc → null. Three clicks returns to the original order. Users expect this. Don't skip the null state.
Adding a Live Filter Input
The filter is a single text input that searches across all string columns. You can make it column-specific later, but a global search covers 80% of real use cases and it's what users actually reach for first.
Chain the filter after the sort inside another useMemo:
``tsx
const filteredData = useMemo(() => {
if (!query.trim()) return sortedData;
const q = query.toLowerCase();
return sortedData.filter((row) =>
columns.some((col) => {
const val = row[col.key];
return typeof val === 'string' && val.toLowerCase().includes(q);
})
);
}, [sortedData, query, columns]);
``
This intentionally skips number columns. If you want numeric range filters, that's a separate feature — and honestly, a separate input with its own state rather than bolting more logic onto this string check.
Wire up a controlled <input> at the top of the table. Give it a 320px min-width, a ring-2 ring-indigo-500 focus style from Tailwind, and an 8px gap between it and the table header row. Small details like that spacing make the component feel intentional rather than slapped together.
Pagination: Slice, Don't Virtualise
For most dashboards, pagination beats virtualisation. It's simpler, it's accessible by default, and it works fine until you're rendering tens of thousands of rows — which is a backend problem, not a frontend one.
Track a currentPage integer and a pageSize (10 or 25 are good defaults). Derive the visible slice:
``tsx
const totalPages = Math.ceil(filteredData.length / pageSize);
const pageData = filteredData.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize
);
`
Reset currentPage to 1 inside a useEffect that watches query and sortKey`. If someone filters the list down to 3 results while they're on page 4, they'll see an empty table. That's a bad experience and a very fixable one.
Render prev/next buttons plus numbered page buttons. Don't render more than 7 page numbers — use an ellipsis beyond that. The pattern is: [1] ... [4] [5] [6] ... [12]. It's what every table in every admin panel uses and users already understand it.
Styling with Tailwind v4.0.2
Tailwind v4.0.2 brings a CSS-first config via @theme instead of tailwind.config.js. That changes almost nothing for table styles, but it does mean your custom design tokens — like a table row hover colour — live in your CSS file rather than a JS object.
A clean base for the table header:
``tsx
<thead className="bg-neutral-900 text-neutral-400 text-xs uppercase tracking-wider">
<tr>
{columns.map((col) => (
<th
key={String(col.key)}
onClick={() => col.sortable && handleSort(col.key)}
className={[
'px-4 py-3 text-left select-none',
col.sortable ? 'cursor-pointer hover:text-white transition-colors' : '',
].join(' ')}
>
{col.label}
{sortKey === col.key && (
<span className="ml-1.5 opacity-70">
{sortDir === 'asc' ? '↑' : '↓'}
</span>
)}
</th>
))}
</tr>
</thead>
`
That select-none` on the header prevents the browser from highlighting text when a user double-clicks a column to sort it twice. It's a tiny thing that removes a constant visual annoyance.
For zebra striping, use even:bg-neutral-800/40 on <tr> elements. It's subtle — rgba(255,255,255,0.04) territory — just enough to help the eye track across a wide row without making the table look like a spreadsheet.
Combining It With Other Empire UI Components
A table rarely lives alone. In a real dashboard it sits next to stat cards, navigation tabs, and action buttons. Empire UI's component system is built so things compose without fighting each other.
You might put an animated tab bar above the table to switch between different data views — active users, churned users, trial users. Each tab swaps the data prop passed to the table. The column definitions can stay the same or change per tab. Either way, the table component doesn't care.
For empty states, consider pairing this with an animated button that links to a create flow. An empty table with a plain 'No results' message feels abandoned. A table with a clear call-to-action in the empty state feels like a product. And if your dashboard has a theme toggle, make sure your table's background tokens (that bg-neutral-900 header) are mapped to CSS variables so they flip correctly in light mode.
Accessibility and Keyboard Navigation
Screen readers need <table>, <thead>, <tbody>, <th>, and <td> — not <div> soup. If you're using a table-like layout built from divs for some CSS Grid reason, you need role="table", role="row", role="columnheader", etc. It's more work. Just use semantic HTML.
Sort buttons in the header should be actual <button> elements, or at minimum have tabIndex={0} and a keyDown handler that fires on Enter and Space. Make the current sort direction announced to screen readers with aria-sort="ascending" or aria-sort="descending" on the <th>. It's one attribute and it makes the component usable to a much wider group of people.
Pagination controls need aria-label on prev/next buttons ('Go to previous page', 'Go to next page') and aria-current="page" on the active page number. What's the point of building a clean table component if half your users can't navigate it?
FAQ
No. TanStack Table is excellent for complex cases — virtualised rows, nested groups, column pinning — but for most dashboards a few useMemo hooks over a plain array is enough. You get full control and zero bundle cost.
The sort comparator in the useMemo block already works for numbers and Date objects since JavaScript's < and > operators handle them correctly. For date strings like '2026-11-14', ISO 8601 format sorts lexicographically in the right order. For localised date strings you'll need to parse them first with new Date() before comparing.
You need a useEffect that watches the filter query (and optionally the sort key) and calls setCurrentPage(1) inside it. Without this, React batches the state updates inconsistently and you'll see the stale page index on the first render after the query changes.
Pass a render callback in your ColumnDef: { key: 'status', label: 'Status', render: (val) => <StatusBadge status={val as string} /> }. The table component calls col.render(row[col.key], row) inside the <td> when render is defined, and falls back to String(val) when it isn't.
Yes, but it's a separate feature from sorting. Store column order in a useState array of column keys, then reorder that array using the HTML Drag and Drop API or a library like @dnd-kit/core. The column definitions themselves don't need to change — you just change the order they're mapped over when rendering headers and cells.
10 rows is the most common default and it works well on mobile. Give users a page-size selector (10 / 25 / 50 are standard options). Store the preference in localStorage so it persists across sessions. Don't default to 50 — it makes the initial page load feel slow even if the data is already in memory, because rendering 50 rows of complex cells takes measurably longer than 10.