TanStack Table (formerly React Table v7+) is the most practical starting point for sortable, filterable data tables in React. It is headless - meaning it handles state and logic but does not provide any visual components - which lets you integrate it with whatever styling system your project uses. This guide walks through a complete implementation with sorting, column filtering, and global search, built on the TanStack Table API.
Step 1: Install and Set Up TanStack Table
npm install @tanstack/react-table
The core API centers on a useReactTable hook that takes your data, column definitions, and state configuration, and returns a table instance you use to render the UI.
Here is a minimal working setup:
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
type ColumnDef,
type SortingState,
type ColumnFiltersState,
} from "@tanstack/react-table";
import { useState } from "react";
type User = {
id: number;
name: string;
email: string;
role: string;
status: "active" | "inactive";
};
const columns: ColumnDef<User>[] = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "email", header: "Email" },
{ accessorKey: "role", header: "Role" },
{ accessorKey: "status", header: "Status" },
];
The ColumnDef type is generic over your row data type. Each column definition maps an accessorKey (the data property name) to a header label and optionally to cell renderer functions for custom formatting.
Step 2: Wire Up Sorting State
function DataTable({ data }: { data: User[] }) {
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
aria-sort={
header.column.getIsSorted() === "asc"
? "ascending"
: header.column.getIsSorted() === "desc"
? "descending"
: "none"
}
style={{ cursor: header.column.getCanSort() ? "pointer" : "default" }}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === "asc" ? " ↑" : ""}
{header.column.getIsSorted() === "desc" ? " ↓" : ""}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
The aria-sort attribute on each <th> is important for accessibility. It communicates the current sort state to screen readers without additional visible text. MDN documents the valid values for aria-sort. The directional arrow appended to the header is the visual indicator; aria-sort is the accessible one.
Step 3: Add Column Filtering
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useReactTable({
data,
columns,
state: { sorting, columnFilters },
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
For each filterable column, add a filter input below the header:
{header.column.getCanFilter() && (
<input
value={(header.column.getFilterValue() as string) ?? ""}
onChange={(e) => header.column.setFilterValue(e.target.value)}
placeholder={`Filter ${header.column.id}...`}
aria-label={`Filter by ${header.column.id}`}
/>
)}
By default, all columns support filtering. To restrict filtering to specific columns, set enableColumnFilter: false in those columns' ColumnDef entries.
The clear-all pattern is implemented by resetting the filter state:
<button
onClick={() => setColumnFilters([])}
disabled={columnFilters.length === 0}
>
Clear all filters ({columnFilters.length})
</button>
Disabling the button when no filters are active prevents the confusing state of a visible control that does nothing.
Step 4: Add Global Search
Global search filters across all visible columns. TanStack Table handles this with a globalFilter state:
const [globalFilter, setGlobalFilter] = useState("");
const table = useReactTable({
data,
columns,
state: { sorting, columnFilters, globalFilter },
onGlobalFilterChange: setGlobalFilter,
// ... other config
getFilteredRowModel: getFilteredRowModel(),
});
The search input:
<input
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="Search all columns..."
aria-label="Search table"
/>
Global filter and column filters compose correctly - both apply simultaneously. A user can set a column filter for "Status: active" and a global search for a name, and the table shows rows that match both conditions.
Step 5: Configuring Which Columns Are Sortable or Filterable
Not every column should be sortable or filterable. A "Notes" column with arbitrary text is rarely useful for sorting. An ID column rarely needs filtering.
Control this per column in the ColumnDef:
const columns: ColumnDef<User>[] = [
{
accessorKey: "id",
header: "ID",
enableSorting: false,
enableColumnFilter: false,
},
{
accessorKey: "name",
header: "Name",
enableSorting: true,
enableColumnFilter: true,
},
{
accessorKey: "status",
header: "Status",
enableSorting: false,
enableColumnFilter: true,
cell: ({ getValue }) => (
<span className={`badge badge-${getValue()}`}>
{getValue() as string}
</span>
),
},
];
The cell property on the Status column shows how to add custom rendering. The flexRender call in the table body handles both default (raw value) and custom (function) cell renderers.
Step 6: Initial Sort State
Set a default sort so the table has a sensible order when it loads:
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
This sets the table to sort by the "name" column ascending on mount. Users can override it by clicking column headers. Making the initial sort match the primary task (most recent first for date columns, alphabetical for name columns) reduces the number of interactions users need before the data is useful.
Step 7: Memoizing Sort and Filter for Performance
For large datasets where client-side sorting and filtering are processing significant data, wrap expensive computations in useMemo:
import { useMemo } from "react";
// Outside the component, define a stable column array:
const columns = useMemo<ColumnDef<User>[]>(
() => [
{ accessorKey: "name", header: "Name", enableSorting: true },
{ accessorKey: "email", header: "Email" },
{ accessorKey: "role", header: "Role", enableColumnFilter: true },
{ accessorKey: "status", header: "Status", enableColumnFilter: true },
],
[]
);
// Data from an API response should also be memoized or stable:
const tableData = useMemo(() => data ?? [], [data]);
Unstable column arrays (recreated on every render) cause TanStack Table to recalculate row models unnecessarily. If you define columns inside the component function body without useMemo, each parent re-render triggers a full table recalculation even when the data has not changed.
For very large datasets (5,000+ rows) where client-side filtering still feels slow, move filtering to the server: set manualFiltering: true in the table config and apply filter state to your API request parameters instead of the client-side filter model. TanStack Table continues to manage the filter state; the actual row reduction happens on the server. MDN covers the underlying browser performance considerations for large DOM trees that inform when virtualization becomes necessary.
Going Further
The implementation above handles the core cases. TanStack Table also supports:
- Row selection with
getRowSelectionModel()for bulk actions - Pagination with
getPaginationRowModel()and configurable page size - Virtual scrolling via integration with TanStack Virtual
- Server-side operations (sorting and filtering handled by the server rather than the client) for very large datasets
AG Grid is the alternative to consider when requirements include advanced features like column grouping, pivot tables, tree data, or built-in export. It handles the enterprise use case with less custom code than TanStack Table but with a larger bundle and a steeper API learning curve.
The UX patterns that inform which features to implement and how to design the interactions around sorting, filtering, selection, and editing are covered in detail in How to Design Data Tables for Complex Web Applications. 137Foundry builds data-intensive web applications where these table patterns are a core part of the product.

Top comments (0)