React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

shadcndata-tabletanstack-tablesortingfilteringpagination

Data Table

Build a DataTable with TanStack Table, shadcn, sorting, filtering, and pagination.

Recipe

Quick-reference recipe card — copy-paste ready.

npx shadcn@latest add table button input
npm install @tanstack/react-table
"use client";
 
import {
  ColumnDef, flexRender, getCoreRowModel,
  useReactTable,
} from "@tanstack/react-table";
import {
  Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
 
interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
}
 
export function DataTable<TData, TValue>({
  columns, data,
}: DataTableProps<TData, TValue>) {
  const table = useReactTable({
    data, columns, getCoreRowModel: getCoreRowModel(),
  });
 
  return (
    <div className="rounded-md border">
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map((hg) => (
            <TableRow key={hg.id}>
              {hg.headers.map((h) => (
                <TableHead key={h.id}>
                  {h.isPlaceholder ? null : flexRender(h.column.columnDef.header, h.getContext())}
                </TableHead>
              ))}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows.length ? (
            table.getRowModel().rows.map((row) => (
              <TableRow key={row.id}>
                {row.getVisibleCells().map((cell) => (
                  <TableCell key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow>
              <TableCell colSpan={columns.length} className="h-24 text-center">
                No results.
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </div>
  );
}

When to reach for this: When you need a feature-rich data table with sorting, filtering, pagination, or row selection.

Working Example

"use client";
 
import { useState } from "react";
import {
  ColumnDef, flexRender, getCoreRowModel, getSortedRowModel,
  getFilteredRowModel, getPaginationRowModel,
  SortingState, ColumnFiltersState,
  useReactTable,
} from "@tanstack/react-table";
import {
  Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArrowUpDown } from "lucide-react";
 
// Data type
type User = {
  id: string;
  name: string;
  email: string;
  role: "admin" | "editor" | "viewer";
  status: "active" | "inactive";
  lastLogin: string;
};
 
// Sample data
const users: User[] = [
  { id: "1", name: "Alice Johnson", email: "alice@example.com", role: "admin", status: "active", lastLogin: "2025-12-01" },
  { id: "2", name: "Bob Smith", email: "bob@example.com", role: "editor", status: "active", lastLogin: "2025-11-28" },
  { id: "3", name: "Charlie Brown", email: "charlie@example.com", role: "viewer", status: "inactive", lastLogin: "2025-10-15" },
  { id: "4", name: "Diana Prince", email: "diana@example.com", role: "admin", status: "active", lastLogin: "2025-12-02" },
  { id: "5", name: "Eve Wilson", email: "eve@example.com", role: "editor", status: "inactive", lastLogin: "2025-09-20" },
  { id: "6", name: "Frank Castle", email: "frank@example.com", role: "viewer", status: "active", lastLogin: "2025-11-30" },
];
 
// Column definitions
const columns: ColumnDef<User>[] = [
  {
    accessorKey: "name",
    header: ({ column }) => (
      <Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
        Name <ArrowUpDown className="ml-2 h-4 w-4" />
      </Button>
    ),
  },
  {
    accessorKey: "email",
    header: "Email",
  },
  {
    accessorKey: "role",
    header: "Role",
    cell: ({ row }) => (
      <span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium capitalize">
        {row.getValue("role")}
      </span>
    ),
  },
  {
    accessorKey: "status",
    header: "Status",
    cell: ({ row }) => {
      const status = row.getValue("status") as string;
      return (
        <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
          status === "active" ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"
        }`}>
          {status}
        </span>
      );
    },
  },
  {
    accessorKey: "lastLogin",
    header: ({ column }) => (
      <Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
        Last Login <ArrowUpDown className="ml-2 h-4 w-4" />
      </Button>
    ),
  },
];
 
export function UsersTable() {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
 
  const table = useReactTable({
    data: users,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    state: { sorting, columnFilters },
    initialState: { pagination: { pageSize: 4 } },
  });
 
  return (
    <div className="space-y-4">
      {/* Filter */}
      <Input
        placeholder="Filter by name..."
        value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
        onChange={(e) => table.getColumn("name")?.setFilterValue(e.target.value)}
        className="max-w-sm"
      />
 
      {/* Table */}
      <div className="rounded-md border">
        <Table>
          <TableHeader>
            {table.getHeaderGroups().map((hg) => (
              <TableRow key={hg.id}>
                {hg.headers.map((h) => (
                  <TableHead key={h.id}>
                    {h.isPlaceholder ? null : flexRender(h.column.columnDef.header, h.getContext())}
                  </TableHead>
                ))}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows.length ? (
              table.getRowModel().rows.map((row) => (
                <TableRow key={row.id}>
                  {row.getVisibleCells().map((cell) => (
                    <TableCell key={cell.id}>
                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell colSpan={columns.length} className="h-24 text-center">
                  No results.
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>
 
      {/* Pagination */}
      <div className="flex items-center justify-between">
        <p className="text-sm text-gray-500">
          Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
        </p>
        <div className="flex gap-2">
          <Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
            Previous
          </Button>
          <Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
            Next
          </Button>
        </div>
      </div>
    </div>
  );
}

What this demonstrates:

  • TanStack Table with shadcn Table components
  • Column sorting with toggle button
  • Text filtering on the name column
  • Client-side pagination with page controls
  • Custom cell renderers (badges for role and status)
  • Empty state handling

Deep Dive

How It Works

  • useReactTable is the core hook — it takes data, columns, and feature models
  • Feature models (getCoreRowModel, getSortedRowModel, etc.) are tree-shakeable plugins
  • Column definitions specify how to access data (accessorKey), render headers, and render cells
  • flexRender renders a column definition (which can be a string, component, or function)
  • State (sorting, filters, pagination) is managed externally via React state and passed to the table
  • shadcn Table components are thin wrappers around <table>, <thead>, <tr>, <td> with consistent styling

Variations

Row selection with checkboxes:

import { Checkbox } from "@/components/ui/checkbox";
 
const selectColumn: ColumnDef<User> = {
  id: "select",
  header: ({ table }) => (
    <Checkbox
      checked={table.getIsAllPageRowsSelected()}
      onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
    />
  ),
  cell: ({ row }) => (
    <Checkbox
      checked={row.getIsSelected()}
      onCheckedChange={(v) => row.toggleSelected(!!v)}
    />
  ),
};
 
// Add to columns array
const columns = [selectColumn, ...otherColumns];
 
// Enable row selection in useReactTable
const table = useReactTable({
  ...config,
  enableRowSelection: true,
  onRowSelectionChange: setRowSelection,
  state: { rowSelection },
});

Server-side pagination:

const table = useReactTable({
  data: serverData.rows,
  columns,
  getCoreRowModel: getCoreRowModel(),
  manualPagination: true,
  pageCount: serverData.totalPages,
  onPaginationChange: (updater) => {
    const next = typeof updater === "function"
      ? updater(pagination)
      : updater;
    setPagination(next);
    fetchPage(next.pageIndex, next.pageSize);
  },
  state: { pagination },
});

Row actions dropdown:

import {
  DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MoreHorizontal } from "lucide-react";
 
const actionsColumn: ColumnDef<User> = {
  id: "actions",
  cell: ({ row }) => (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => editUser(row.original)}>Edit</DropdownMenuItem>
        <DropdownMenuItem className="text-red-600" onClick={() => deleteUser(row.original.id)}>
          Delete
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  ),
};

TypeScript Notes

// ColumnDef is generic
const columns: ColumnDef<User>[] = [
  { accessorKey: "name", header: "Name" }, // accessorKey is typed against User
];
 
// row.original is fully typed
cell: ({ row }) => {
  const user: User = row.original;
  return <span>{user.email}</span>;
};
 
// row.getValue is typed by column
row.getValue("status"); // typed by accessorKey
 
// Generic DataTable component
interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
}

Gotchas

  • Column definitions must be stable — Defining columns inside the component causes infinite re-renders. Fix: Define columns outside the component or wrap in useMemo.

  • accessorKey vs accessorFnaccessorKey is for simple property access; accessorFn is for computed or nested values. Mixing them up causes type issues.

  • Server-side pagination resets on filter — When filtering changes, you need to reset to page 0. Fix: Reset pageIndex when filters change.

  • Large datasets — Client-side sorting/filtering with 10,000+ rows can lag. Fix: Use server-side pagination, sorting, and filtering with manual* options.

  • Sticky headers — shadcn Table does not include sticky headers by default. Fix: Add className="sticky top-0 bg-white z-10" to TableHeader.

Alternatives

AlternativeUse WhenDon't Use When
Plain <table>Displaying small, static data without interactivityYou need sorting, filtering, or pagination
AG GridYou need Excel-like features (cell editing, pivot, charts)A simpler table suffices
React VirtuosoYou have thousands of rows and need virtualizationPaginated data fits your UX
Tremor TableYou use the Tremor dashboard libraryYou use shadcn/ui

FAQs

What are the core packages needed to build a DataTable with shadcn?
  • @tanstack/react-table for table logic (sorting, filtering, pagination)
  • shadcn table, button, and input components for the UI
  • Install with npx shadcn@latest add table button input and npm install @tanstack/react-table
What is flexRender and why is it needed?
  • flexRender renders a column definition which can be a string, React component, or function
  • It bridges TanStack Table's column config with actual JSX output
  • Used for both header and cell rendering
How do you add sorting to a column?
header: ({ column }) => (
  <Button variant="ghost" onClick={() =>
    column.toggleSorting(column.getIsSorted() === "asc")
  }>
    Name <ArrowUpDown className="ml-2 h-4 w-4" />
  </Button>
),

Also add getSortedRowModel: getSortedRowModel() and onSortingChange: setSorting to useReactTable.

Gotcha: Why do column definitions defined inside the component cause infinite re-renders?
  • React creates a new array reference on every render
  • TanStack Table detects the new reference and re-processes columns, triggering another render
  • Fix: define columns outside the component or wrap in useMemo
How do you implement server-side pagination instead of client-side?
  • Set manualPagination: true and pageCount: serverData.totalPages
  • Use onPaginationChange to fetch the next page from the server
  • Do not include getPaginationRowModel() since the server handles slicing
How do you add row selection with checkboxes?
const selectColumn: ColumnDef<User> = {
  id: "select",
  header: ({ table }) => (
    <Checkbox
      checked={table.getIsAllPageRowsSelected()}
      onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
    />
  ),
  cell: ({ row }) => (
    <Checkbox
      checked={row.getIsSelected()}
      onCheckedChange={(v) => row.toggleSelected(!!v)}
    />
  ),
};
What is the difference between accessorKey and accessorFn?
  • accessorKey is for simple, direct property access (e.g., "name" maps to row.name)
  • accessorFn is for computed or nested values (e.g., (row) => row.address.city)
  • Mixing them up causes TypeScript type issues
How is row.original typed in TanStack Table?
  • row.original is fully typed as your data type (e.g., User)
  • The generic ColumnDef<User> propagates through so row.original.email is type-safe
Gotcha: Why does filtering break when you change pages but don't reset to page 0?
  • Server-side pagination does not automatically reset pageIndex when filters change
  • You may end up requesting a page that no longer exists
  • Fix: reset pageIndex to 0 whenever filters change
How do you add a row actions dropdown to each row?
const actionsColumn: ColumnDef<User> = {
  id: "actions",
  cell: ({ row }) => (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" size="icon">
          <MoreHorizontal className="h-4 w-4" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => editUser(row.original)}>Edit</DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  ),
};
How do you type a generic reusable DataTable component?
interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
}
 
export function DataTable<TData, TValue>({
  columns, data
}: DataTableProps<TData, TValue>) { ... }
How do you make table headers sticky?
  • shadcn Table does not include sticky headers by default
  • Add className="sticky top-0 bg-white z-10" to TableHeader
  • Ensure the table container has a fixed height with overflow-y: auto
  • Setup — installing shadcn components
  • Button — table action buttons
  • Dialog — edit forms in dialogs triggered from rows
  • Command — search/filter UI