DEV Community

137Foundry
137Foundry

Posted on

How to Implement Sortable, Filterable Data Tables in React with TanStack Table

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
Enter fullscreen mode Exit fullscreen mode

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" },
];
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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(),
});
Enter fullscreen mode Exit fullscreen mode

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}`}
  />
)}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Disabling the button when no filters are active prevents the confusing state of a visible control that does nothing.

react typescript code editor programming
Photo by 10007528 on Pixabay

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(),
});
Enter fullscreen mode Exit fullscreen mode

The search input:

<input
  value={globalFilter}
  onChange={(e) => setGlobalFilter(e.target.value)}
  placeholder="Search all columns..."
  aria-label="Search table"
/>
Enter fullscreen mode Exit fullscreen mode

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>
    ),
  },
];
Enter fullscreen mode Exit fullscreen mode

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 },
]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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)