GitHub

DataTable

A fully-featured table built on TanStack Table v8 with sorting, filtering, and pagination out of the box. Renders using the Table primitive components from Issue #125.

TanStack Table v8SortableFilterablePaginatedLoading StateEmpty State

Install

$npx react-principles add data-table
01

Features

  • Sorting: click column headers to sort asc/desc/none
  • Global filter: search input filters across all columns
  • Pagination: Previous/Next controls with page info
  • Loading state: skeleton rows while data loads
  • Empty state: custom content when no rows match
  • Controlled & uncontrolled: all state (sorting, filter, pagination) supports both modes
  • TypeScript generic: DataTable<TData> for type-safe columns
02

Live Demo

Fully interactive — try sorting columns, filtering rows, and navigating pages.

search
Name
Email
Role
Status
Created
Alice Johnsonalice@example.comadminactiveJan 15, 2024
Bob Smithbob@example.comeditoractiveFeb 10, 2024
Carol Williamscarol@example.comvieweractiveFeb 20, 2024
David Browndavid@example.comeditorinactiveMar 5, 2024
Eva Martinezeva@example.comadminactiveMar 12, 2024
Frank Garciafrank@example.comvieweractiveMar 18, 2024
Grace Leegrace@example.comeditoractiveApr 1, 2024
Henry Wilsonhenry@example.comviewerinactiveApr 10, 2024

Page 1 of 3 (20 rows)

03

Code Snippet

src/ui/DataTable.tsx
import { DataTable } from "@/ui/DataTable";
import type { ColumnDef } from "@tanstack/react-table";
import { useMemo } from "react";

interface User {
  id: string;
  name: string;
  email: string;
  role: string;
  status: string;
}

const columns: ColumnDef<User>[] = [
  { accessorKey: "name", header: "Name" },
  { accessorKey: "email", header: "Email" },
  { accessorKey: "role", header: "Role" },
  { accessorKey: "status", header: "Status" },
];

export function UserList() {
  const data = useMemo(() => fetchUsers(), []);

  return (
    <DataTable
      columns={columns}
      data={data}
      pageSize={8}
      filterPlaceholder="Search users..."
    />
  );
}
04

Copy-Paste (Single File)

DataTable.tsx
"use client";

import { useState, type ReactNode } from "react";
import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  flexRender,
  type ColumnDef,
  type SortingState,
  type PaginationState,
  type OnChangeFn,
} from "@tanstack/react-table";
import { cn } from "@/lib/utils";
import { Table } from "./Table";
import { Button } from "./Button";
import { Skeleton } from "./Skeleton";

export interface DataTableProps<TData> {
  columns: Array<ColumnDef<TData>>;
  data: TData[];
  sorting?: SortingState;
  onSortingChange?: OnChangeFn<SortingState>;
  globalFilter?: string;
  onGlobalFilterChange?: OnChangeFn<string>;
  pagination?: PaginationState;
  onPaginationChange?: OnChangeFn<PaginationState>;
  pageSize?: number;
  isLoading?: boolean;
  emptyState?: ReactNode;
  filterPlaceholder?: string;
  className?: string;
}

function SortIcon({ direction }: { direction: false | "asc" | "desc" }) {
  if (!direction) return null;
  return (
    <span className="material-symbols-outlined text-[14px]">
      {direction === "asc" ? "arrow_upward" : "arrow_downward"}
    </span>
  );
}

function DataTableSkeleton({ colCount, rowCount }: { colCount: number; rowCount: number }) {
  return (
    <>
      {Array.from({ length: rowCount }).map((_, i) => (
        <Table.Row key={i}>
          {Array.from({ length: colCount }).map((__, j) => (
            <Table.Cell key={j}>
              <Skeleton variant="line" className="h-4 w-full" />
            </Table.Cell>
          ))}
        </Table.Row>
      ))}
    </>
  );
}

export function DataTable<TData>({
  columns,
  data,
  sorting: controlledSorting,
  onSortingChange,
  globalFilter: controlledFilter,
  onGlobalFilterChange,
  pagination: controlledPagination,
  onPaginationChange,
  pageSize = 10,
  isLoading = false,
  emptyState,
  filterPlaceholder = "Filter all columns...",
  className,
}: DataTableProps<TData>) {
  const [internalSorting, setInternalSorting] = useState<SortingState>([]);
  const [internalFilter, setInternalFilter] = useState("");
  const [internalPagination, setInternalPagination] = useState<PaginationState>({
    pageIndex: 0,
    pageSize,
  });

  const sorting = controlledSorting ?? internalSorting;
  const globalFilter = controlledFilter ?? internalFilter;
  const pagination = controlledPagination ?? internalPagination;

  const isSortingControlled = controlledSorting !== undefined;
  const isFilterControlled = controlledFilter !== undefined;
  const isPaginationControlled = controlledPagination !== undefined;

  const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
    const next = typeof updater === "function" ? updater(sorting) : updater;
    if (!isSortingControlled) setInternalSorting(next);
    onSortingChange?.(updater);
  };

  const handleFilterChange: OnChangeFn<string> = (updater) => {
    const next = typeof updater === "function" ? updater(globalFilter) : updater;
    if (!isFilterControlled) setInternalFilter(next);
    onGlobalFilterChange?.(updater);
  };

  const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
    const next = typeof updater === "function" ? updater(pagination) : updater;
    if (!isPaginationControlled) setInternalPagination(next);
    onPaginationChange?.(updater);
  };

  const table = useReactTable({
    data,
    columns,
    state: { sorting, globalFilter, pagination },
    onSortingChange: handleSortingChange,
    onGlobalFilterChange: handleFilterChange,
    onPaginationChange: handlePaginationChange,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  });

  const isEmpty = !isLoading && table.getRowModel().rows.length === 0;

  return (
    <div className={cn("space-y-4", className)}>
      <div className="relative">
        <span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 material-symbols-outlined text-[18px] text-slate-400">
          search
        </span>
        <input
          type="text"
          value={globalFilter}
          onChange={(e) => handleFilterChange(e.target.value)}
          placeholder={filterPlaceholder}
          className={cn(
            "h-10 w-full rounded-lg border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-900 outline-hidden transition-all",
            "hover:border-slate-300 focus:border-primary focus:ring-2 focus:ring-primary/20",
            "dark:border-[#1f2937] dark:bg-[#0d1117] dark:text-white dark:hover:border-slate-600",
          )}
        />
      </div>

      <Table>
        <Table.Header>
          {table.getHeaderGroups().map((headerGroup) => (
            <Table.Row key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <Table.Head
                  key={header.id}
                  className={cn(header.column.getCanSort() && "cursor-pointer select-none")}
                  onClick={header.column.getToggleSortingHandler()}
                >
                  <div className="flex items-center gap-1">
                    {flexRender(header.column.columnDef.header, header.getContext())}
                    <SortIcon direction={header.column.getIsSorted()} />
                  </div>
                </Table.Head>
              ))}
            </Table.Row>
          ))}
        </Table.Header>
        <Table.Body>
          {isLoading ? (
            <DataTableSkeleton colCount={columns.length} rowCount={pagination.pageSize} />
          ) : isEmpty ? (
            <Table.Row>
              <Table.Cell colSpan={columns.length} className="h-48 text-center">
                {emptyState ?? (
                  <p className="text-sm text-slate-500 dark:text-slate-400">No results found.</p>
                )}
              </Table.Cell>
            </Table.Row>
          ) : (
            table.getRowModel().rows.map((row) => (
              <Table.Row key={row.id}>
                {row.getVisibleCells().map((cell) => (
                  <Table.Cell key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </Table.Cell>
                ))}
              </Table.Row>
            ))
          )}
        </Table.Body>
      </Table>

      <div className="flex items-center justify-between">
        <p className="text-sm text-slate-500 dark:text-slate-400">
          Page {pagination.pageIndex + 1} of {table.getPageCount()}{" "}
          ({table.getFilteredRowModel().rows.length} rows)
        </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>
  );
}
05

Props

PropTypeDefaultDescription
columnsColumnDef<TData>[]TanStack Table column definitions.
dataTData[]Array of row data to display.
sortingSortingStateControlled sorting state.
onSortingChangeOnChangeFn<SortingState>Called when sorting changes.
globalFilterstringControlled global filter string.
onGlobalFilterChangeOnChangeFn<string>Called when the filter changes.
paginationPaginationStateControlled pagination state.
onPaginationChangeOnChangeFn<PaginationState>Called when pagination changes.
pageSizenumber10Initial page size in uncontrolled mode.
isLoadingbooleanfalseShows skeleton rows while loading.
emptyStateReactNodeCustom content rendered when no rows match.
filterPlaceholderstring"Filter all columns..."Placeholder text for the filter input.
React Principles