React Data Table Examples with TanStack Table

TanStack Table is a headless React table library: it handles sorting, filtering, pagination and selection while you write the markup. In version 9 you register each feature with tableFeatures, add a row model per client-side step, call useTable, and render headers and cells with FlexRender. The examples below are type-checked against 9.3.

Choose an approach
RowsApproach
Under about 50, no interactionA plain HTML table
Hundreds to a few thousandTanStack Table, client side
Tens of thousands or moreTanStack Table with manual (server) sorting and paging
Very long single pagesAdd row virtualisation

Updated

Try the finished table, then copy the code that builds it. The live preview on this page is drawn with plain React state so the page stays light; the code beside it is the TanStack v9 version that does the same thing in your app.

Want a finished, styled version to copy? See our Tailwind React data table component.

Set up TanStack Table

Install

One package. It has no styles and renders nothing by itself: you write the table markup, which is why it fits Tailwind so well.

npm install @tanstack/react-table

The smallest working table

Features are listed up front and passed in once. Keep features, columns and the data array stable (outside the component or in state), or the table rebuilds its rows on every render.

import { createColumnHelper, tableFeatures, useTable } from "@tanstack/react-table";

type Person = { name: string; city: string };
const features = tableFeatures({});
const helper = createColumnHelper<typeof features, Person>();
const columns = helper.columns([
  helper.accessor("name", { header: "Name" }),
  helper.accessor("city", { header: "City" }),
]);
const data: Person[] = [{ name: "Ada", city: "Leeds" }, { name: "Lin", city: "Perth" }];

export function People() {
  const table = useTable({ features, columns, data });
  return (
    <table>
      <thead>
        {table.getHeaderGroups().map((g) => (
          <tr key={g.id}>{g.headers.map((h) => <th key={h.id}><table.FlexRender header={h} /></th>)}</tr>
        ))}
      </thead>
      <tbody>
        {table.getRowModel().rows.map((r) => (
          <tr key={r.id}>{r.getAllCells().map((c) => <td key={c.id}><table.FlexRender cell={c} /></td>)}</tr>
        ))}
      </tbody>
    </table>
  );
}

Features and row models

What to register for each behaviour (v9)
You wantFeatureRow-model slot (client side)
Click a header to sortrowSortingFeaturesortedRowModel: createSortedRowModel()
A search box over all columnscolumnFilteringFeature + globalFilteringFeaturefilteredRowModel: createFilteredRowModel()
Filters per columncolumnFilteringFeaturefilteredRowModel: createFilteredRowModel()
Pages of rowsrowPaginationFeaturepaginatedRowModel: createPaginatedRowModel()
Checkbox selectionrowSelectionFeatureNone
Show and hide columnscolumnVisibilityFeatureNone
Server does the workThe same featuresNo slots; set manualSorting, manualFiltering, manualPagination

A complete data table

Sort, search, paginate, select, hide columns, sticky header

Everything in one component, styled with Tailwind classes. The header row stays in place because the wrapper scrolls and the thead is sticky. aria-sort tells screen readers which way a column is sorted.

import {
  columnFilteringFeature,
  columnVisibilityFeature,
  createColumnHelper,
  createFilteredRowModel,
  createPaginatedRowModel,
  createSortedRowModel,
  filterFn_includesString,
  globalFilteringFeature,
  rowPaginationFeature,
  rowSelectionFeature,
  rowSortingFeature,
  sortFn_alphanumeric,
  sortFn_basic,
  tableFeatures,
  useTable,
} from "@tanstack/react-table";

type Order = { id: string; customer: string; status: "Paid" | "Pending" | "Refunded"; total: number };

const features = tableFeatures({
  rowSortingFeature,
  sortedRowModel: createSortedRowModel(),
  sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
  columnFilteringFeature,
  globalFilteringFeature,
  filteredRowModel: createFilteredRowModel(),
  filterFns: { includesString: filterFn_includesString },
  rowPaginationFeature,
  paginatedRowModel: createPaginatedRowModel(),
  rowSelectionFeature,
  columnVisibilityFeature,
});

const helper = createColumnHelper<typeof features, Order>();
const columns = helper.columns([
  helper.display({
    id: "select",
    header: ({ table }) => (
      <input type="checkbox" aria-label="Select all rows on this page"
        checked={table.getIsAllPageRowsSelected()}
        onChange={table.getToggleAllPageRowsSelectedHandler()} />
    ),
    cell: ({ row }) => (
      <input type="checkbox" aria-label={`Select order ${row.original.id}`}
        checked={row.getIsSelected()}
        onChange={row.getToggleSelectedHandler()} />
    ),
    enableSorting: false,
    enableHiding: false,
  }),
  helper.accessor("id", { header: "Order" }),
  helper.accessor("customer", { header: "Customer", sortFn: "alphanumeric" }),
  helper.accessor("status", { header: "Status" }),
  helper.accessor("total", {
    header: "Total",
    cell: (info) => `$${info.getValue().toFixed(2)}`,
    sortFn: "basic",
  }),
]);

export function OrdersTable({ data }: { data: Order[] }) {
  const table = useTable({
    features,
    columns,
    data,
    getRowId: (row) => row.id,
    globalFilterFn: "includesString",
    initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
  });

  return (
    <div>
      <div className="flex flex-wrap gap-3 pb-3">
        <input
          value={String(table.state.globalFilter ?? "")}
          onChange={(e) => table.setGlobalFilter(e.target.value)}
          placeholder="Search orders"
          aria-label="Search orders"
          className="rounded-lg border px-3 py-1.5"
        />
        {table.getAllLeafColumns().filter((c) => c.getCanHide()).map((c) => (
          <label key={c.id} className="text-sm">
            <input type="checkbox" checked={c.getIsVisible()} onChange={c.getToggleVisibilityHandler()} /> {c.id}
          </label>
        ))}
      </div>

      <div className="max-h-96 overflow-auto rounded-xl border">
        <table className="w-full text-sm">
          <thead className="sticky top-0 bg-gray-50">
            {table.getHeaderGroups().map((group) => (
              <tr key={group.id}>
                {group.headers.map((header) => {
                  const sorted = header.column.getIsSorted();
                  return (
                    <th key={header.id} className="px-3 py-2 text-left"
                      aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : undefined}>
                      {header.column.getCanSort() ? (
                        <button onClick={header.column.getToggleSortingHandler()}>
                          <table.FlexRender header={header} />
                          {sorted === "asc" ? " ▲" : sorted === "desc" ? " ▼" : ""}
                        </button>
                      ) : (
                        <table.FlexRender header={header} />
                      )}
                    </th>
                  );
                })}
              </tr>
            ))}
          </thead>
          <tbody>
            {table.getRowModel().rows.map((row) => (
              <tr key={row.id} className={row.getIsSelected() ? "bg-blue-50" : ""}>
                {row.getVisibleCells().map((cell) => (
                  <td key={cell.id} className="border-t px-3 py-2">
                    <table.FlexRender cell={cell} />
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <div className="flex items-center gap-2 pt-3 text-sm">
        <button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Previous</button>
        <span>Page {table.state.pagination.pageIndex + 1} of {table.getPageCount()}</span>
        <button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</button>
        <span>{Object.keys(table.state.rowSelection).length} selected</span>
      </div>
    </div>
  );
}

Server-side data

Server-side sorting and pagination

For large datasets the server sorts and returns one page. Leave out the row-model slots, set the manual flags, and pass rowCount so the table knows how many pages exist. The sort and page state drive the request.

import { useEffect, useState } from "react";
import {
  createColumnHelper, rowPaginationFeature, rowSortingFeature, tableFeatures, useTable,
  type PaginationState, type SortingState,
} from "@tanstack/react-table";

type Order = { id: string; customer: string; total: number };

// No row-model slots: the server sorts and slices, the table only displays.
const features = tableFeatures({ rowSortingFeature, rowPaginationFeature });
const helper = createColumnHelper<typeof features, Order>();
const columns = helper.columns([
  helper.accessor("customer", { header: "Customer" }),
  helper.accessor("total", { header: "Total" }),
]);
const EMPTY: Order[] = [];

export function ServerOrders() {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 25 });
  const [page, setPage] = useState<{ rows: Order[]; total: number } | null>(null);

  useEffect(() => {
    const ctrl = new AbortController();
    const sort = sorting[0] ? `${sorting[0].id}.${sorting[0].desc ? "desc" : "asc"}` : "";
    const qs = new URLSearchParams({ page: String(pagination.pageIndex), size: String(pagination.pageSize), sort });
    fetch(`/api/orders?${qs}`, { signal: ctrl.signal })
      .then((r) => r.json())
      .then(setPage)
      .catch((e) => { if (e.name !== "AbortError") console.error(e); });
    return () => ctrl.abort();
  }, [sorting, pagination]);

  const table = useTable({
    features,
    columns,
    data: page?.rows ?? EMPTY,
    manualSorting: true,
    manualPagination: true,
    rowCount: page?.total ?? 0,
    state: { sorting, pagination },
    onSortingChange: setSorting,
    onPaginationChange: setPagination,
  });

  return (
    <p>
      Page {pagination.pageIndex + 1} of {table.getPageCount()}
      <button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</button>
    </p>
  );
}

Upgrading from v8

Coming from v8
v8v9
useReactTable({ getCoreRowModel: getCoreRowModel(), ... })useTable({ features, ... }); the core row model is automatic
getSortedRowModel: getSortedRowModel()rowSortingFeature plus sortedRowModel: createSortedRowModel() in tableFeatures
table.getState().sortingtable.state.sorting
flexRender(cell.column.columnDef.cell, cell.getContext())<table.FlexRender cell={cell} /> (flexRender still works)
Everything bundled by defaultOnly the features you register are bundled

Ready-made styling

If you would rather start from a finished design, our component gallery has a Tailwind data table with sorting, search, row selection and pagination that you can copy as React or HTML. For shadcn/ui projects, see our shadcn form and table guide.

Common mistakes

Declaring columns inside the component without memoising them, which rebuilds the table every render.

Turning on manualPagination but still passing every row, so the first page shows everything.

Reversing the order inside a custom sort function. Return an ascending comparison; the library flips it for descending.

How it works

  1. Install @tanstack/react-table.
  2. List the features you need in tableFeatures, with a row-model slot for each step done in the browser.
  3. Define columns with createColumnHelper, outside the component.
  4. Call useTable with features, columns and data.
  5. Render header groups and rows with your own markup and FlexRender.

Tips

Frequently asked questions

Is React Table the same as TanStack Table?

Yes. react-table v7 was renamed TanStack Table from v8 and published as @tanstack/react-table. Version 9 keeps the headless idea but asks you to register features explicitly, which keeps bundles smaller.

Does TanStack Table include styles?

No. It gives you state and row models only. You write the table, th and td elements and style them with Tailwind, CSS or a component library such as shadcn/ui.

Should sorting and pagination happen on the server?

When the full dataset is too big to send to the browser, yes. Set the manual options, send the sort and page state to your API, and pass the total row count so page controls work.

How do I keep the header visible while scrolling?

Give the wrapper a max height and overflow auto, and make the thead sticky with top 0 and a background colour so rows do not show through.

Related: Tailwind React data table component, HTML table generator, React memo, useMemo and useCallback, JSON to HTML table, All free tools.

Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.

More in Guides

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap