EmpireUI
Get Pro
← Blog8 min read#react table#tanstack table#sorting

React Table Component: Sorting, Filtering, Pagination with TanStack

Build a fully sortable, filterable, paginated React table using TanStack Table v8 — no bloat, no magic, just composable hooks and total UI control.

Developer looking at data table rows on a dark monitor screen

Why TanStack Table v8 Is the Right Call

If you've spent any time wrestling with react-table v6 or v7, you know the pain. Opinionated rendering, tangled plugin systems, and a docs site that felt like it was designed to confuse you. TanStack Table v8 — released in 2022 and actively maintained into 2026 — flips all of that. It's headless. You own the markup.

Headless means zero opinions about your DOM. The library gives you state, sorting logic, filter functions, and pagination math. You render whatever you want. Want a <table> element? Fine. Want a CSS grid with divs? Also fine. That's the whole point.

Honestly, most 'table components' you'll find in UI kits are just pre-styled wrappers that break the moment you need something slightly custom — like a sticky header at 64px or a row expansion pattern that doesn't match their assumptions. TanStack Table sidesteps that entirely by staying out of your JSX.

Worth noting: the bundle is tiny. The core package is under 15 KB gzipped. You're not paying a render cost for features you don't use.

Setting Up TanStack Table in a React Project

Install it. One package, no peer dependency drama.

npm install @tanstack/react-table

Now define your columns using createColumnHelper. This is the part that trips people up the first time — you're not writing column objects the old way anymore. The helper gives you full TypeScript inference on your row data, which is genuinely useful when your dataset has 30+ fields.

import {
  createColumnHelper,
  flexRender,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  useReactTable,
} from '@tanstack/react-table';

type User = {
  id: number;
  name: string;
  email: string;
  role: string;
  joined: string;
};

const columnHelper = createColumnHelper<User>();

const columns = [
  columnHelper.accessor('name', {
    header: 'Name',
    cell: (info) => info.getValue(),
  }),
  columnHelper.accessor('email', {
    header: 'Email',
  }),
  columnHelper.accessor('role', {
    header: 'Role',
  }),
  columnHelper.accessor('joined', {
    header: 'Joined',
  }),
];

That's your column definition done. Clean, typed, and composable. You can add custom cell renderers later — badges, buttons, truncation — without touching the sort logic.

Wiring Up Sorting, Filtering, and Pagination

Here's where it all comes together. You pass your data and columns into useReactTable, declare which row models you need, and the hook hands back a fully computed table instance.

import { useState } from 'react';

export function DataTable({ data }: { data: User[] }) {
  const [sorting, setSorting] = useState([]);
  const [globalFilter, setGlobalFilter] = useState('');

  const table = useReactTable({
    data,
    columns,
    state: { sorting, globalFilter },
    onSortingChange: setSorting,
    onGlobalFilterChange: setGlobalFilter,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    initialState: { pagination: { pageSize: 10 } },
  });

  return (
    <div className="w-full">
      <input
        value={globalFilter}
        onChange={(e) => setGlobalFilter(e.target.value)}
        placeholder="Search..."
        className="mb-4 w-full rounded border px-3 py-2 text-sm"
      />
      <table className="w-full border-collapse text-sm">
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th
                  key={header.id}
                  onClick={header.column.getToggleSortingHandler()}
                  className="cursor-pointer border-b px-4 py-2 text-left font-semibold"
                >
                  {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} className="hover:bg-gray-50">
              {row.getVisibleCells().map((cell) => (
                <td key={cell.id} className="border-b px-4 py-2">
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
      <div className="mt-4 flex items-center gap-2">
        <button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Prev</button>
        <span>Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}</span>
        <button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</button>
      </div>
    </div>
  );
}

That single component handles sorting on every column, global text filtering across all fields, and pagination with a configurable page size. In practice, this is about 90% of what most data table UIs actually need.

One more thing — if you want per-column filters instead of global search, swap getFilteredRowModel behavior and add individual filter states per column using column.setFilterValue(). The API is consistent either way.

Styling Your Table to Match Your Design System

Headless means you bring the CSS. That's a feature, not a bug. If your app uses a glassmorphic aesthetic, you can drop in frosted-glass <th> cells and blurred backgrounds — check out the glassmorphism components on Empire UI for patterns that work well with tabular data.

For spacing, a comfortable data density starts at 12px vertical padding per cell (py-3) and 16px horizontal (px-4). Go tighter — say 8px vertical — for compact admin UIs where you're showing 25+ rows at a time. That said, don't go below 8px or touch targets on mobile become a problem.

Stripe alternate rows with a light background. Nothing fancy — even:bg-gray-50 dark:even:bg-gray-800/50 in Tailwind does it. Hover state on rows helps users track which row they're reading. Use transition-colors duration-100 to keep it snappy rather than laggy.

Quick aside: if you're building a premium dashboard UI, browse the components on Empire UI for inspiration on table row states, badge cells, and action menus that stay visually consistent with the rest of your app.

Performance with Large Datasets

Client-side filtering and sorting works great up to roughly 5,000–10,000 rows. Beyond that, you feel it. The filter re-render on every keystroke starts to stutter, especially on lower-end devices.

For large datasets, switch to server-side mode. Set manualSorting, manualFiltering, and manualPagination to true, then handle state changes by firing API requests. TanStack Table doesn't care — it just reflects whatever data you give it for the current page.

const table = useReactTable({
  data,
  columns,
  manualSorting: true,
  manualFiltering: true,
  manualPagination: true,
  pageCount: totalPages, // from your API
  state: { sorting, globalFilter, pagination },
  onSortingChange: (updater) => {
    setSorting(updater);
    // trigger your API fetch here
  },
  getCoreRowModel: getCoreRowModel(),
});

You'd typically debounce the filter input by 300–400ms before firing the API call. That's the right tradeoff between responsiveness and request count. React Query or SWR pair well here — let them handle caching so you're not re-fetching the same sorted+filtered page twice.

Column Visibility, Pinning, and Row Selection

TanStack Table v8 ships column visibility and pinning out of the box. Most tutorials skip this, but these features are the difference between a table that feels like a toy and one that actual users want to live in.

Column visibility is a simple state object: { email: false } hides the email column. Add a checkbox dropdown and users can customize their own view. It's the kind of thing that makes enterprise buyers happy without you writing a single custom algorithm.

Row selection uses a similar pattern — a rowSelection state object keyed by row ID. Call table.getSelectedRowModel().rows to get the selected row data. From there you can wire up bulk-delete, bulk-export, whatever your use case needs.

Look, these features sound small but they're what separates a genuinely useful data table from a static display widget. If you're already using TanStack Table, you're already 90% of the way there — you're just enabling the row models you need.

Integrating with Empire UI's Visual Styles

TanStack Table doesn't care what you render, which means you can skin it with any of the visual systems on Empire UI. Neobrutalism works surprisingly well for tables — thick borders, bold header backgrounds, and high-contrast hover states make the data structure obvious at a glance.

For tools-heavy dashboards, pair your table with the box shadow generator to get the right card shadow for your table container without guessing at CSS values. A box-shadow: 0 2px 12px rgba(0,0,0,0.08) container with a white background reads as a distinct data panel on most designs.

The approach is the same regardless of style: TanStack Table owns the logic, you own the look. Build the column definitions once, theme them however you want. That separation means you can refactor your design system in 2027 without touching your sort logic.

Whatever style direction you're going, Empire UI has prebuilt components you can use as starting points for the cells themselves — status badges, user avatars, action menus — that slot cleanly into TanStack's cell renderer pattern.

FAQ

Is TanStack Table the same as react-table?

Yes — react-table was rebranded to TanStack Table at v8 in 2022. The API changed significantly, so v7 migration guides don't apply. Use the @tanstack/react-table package.

Does TanStack Table work with TypeScript?

Yes, and it's genuinely good TypeScript. The createColumnHelper utility infers your row type through the whole column definition, so you get autocomplete on accessors and cell values.

How do I handle server-side pagination with TanStack Table?

Set manualPagination: true and pass pageCount from your API response. Update your data fetch whenever the pagination state changes using onPaginationChange.

Can I use TanStack Table without Tailwind CSS?

Absolutely. The library is headless — it outputs no styles and no class names. Bring plain CSS, CSS modules, styled-components, or any other styling approach.

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

Read next

Data Table in React: Sort, Filter, Pagination With TanStack TableData Table Filters in React: Column Filter, Global Search, URL StateTanStack Table in React: Sorting, Filtering, Pagination in 100 LinesBuilding a Component Library with Storybook 8 + TypeScript