EmpireUI
Get Pro
← Blog7 min read#react#table#sorting

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.

A developer looking at a data table on a monitor with rows and columns of structured information

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

Do I need TanStack Table (React Table v8) to build a sortable table in React?

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.

How do I handle sorting for number and date columns, not just strings?

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.

Why does my filter reset to page 1 not work after filtering?

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.

How do I add a column with a custom render — like a status badge instead of raw text?

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.

Can I make the table columns reorderable by drag-and-drop?

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.

What's the right pageSize default — 10, 25, or 50?

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.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Sticky Data Table in React: Scrollable, Sortable, PerformantEditable Table in React: Click-to-Edit Cell with ValidationGantt Chart in React: Project Timeline without heavy libsDark Pagination Component: Page Navigation for Data Tables