Skip to main content
Back to Blog
Tutorials
4 min read
November 25, 2024

How to Build a Data Table with Sorting and Filtering in React

Create a feature-rich data table with sorting, filtering, pagination, and column visibility using TanStack Table in React.

Ryel Banfield

Founder & Lead Developer

TanStack Table provides headless table logic with complete control over rendering. Here is how to build a full-featured data table.

Step 1: Install Dependencies

pnpm add @tanstack/react-table

Step 2: Define Columns

// components/data-table/columns.tsx
"use client";

import { ColumnDef } from "@tanstack/react-table";

export interface Payment {
  id: string;
  amount: number;
  status: "pending" | "processing" | "success" | "failed";
  email: string;
  createdAt: string;
}

export const columns: ColumnDef<Payment>[] = [
  {
    id: "select",
    header: ({ table }) => (
      <input
        type="checkbox"
        checked={table.getIsAllPageRowsSelected()}
        onChange={table.getToggleAllPageRowsSelectedHandler()}
        aria-label="Select all"
      />
    ),
    cell: ({ row }) => (
      <input
        type="checkbox"
        checked={row.getIsSelected()}
        onChange={row.getToggleSelectedHandler()}
        aria-label="Select row"
      />
    ),
    enableSorting: false,
    enableHiding: false,
  },
  {
    accessorKey: "email",
    header: "Email",
    cell: ({ row }) => <span className="lowercase">{row.getValue("email")}</span>,
  },
  {
    accessorKey: "status",
    header: "Status",
    cell: ({ row }) => {
      const status = row.getValue("status") as string;
      const colors: Record<string, string> = {
        pending: "bg-yellow-100 text-yellow-800",
        processing: "bg-blue-100 text-blue-800",
        success: "bg-green-100 text-green-800",
        failed: "bg-red-100 text-red-800",
      };
      return (
        <span className={`rounded-full px-2 py-1 text-xs font-medium ${colors[status]}`}>
          {status}
        </span>
      );
    },
    filterFn: (row, id, value: string[]) => value.includes(row.getValue(id)),
  },
  {
    accessorKey: "amount",
    header: () => <div className="text-right">Amount</div>,
    cell: ({ row }) => {
      const amount = parseFloat(row.getValue("amount"));
      return (
        <div className="text-right font-medium">
          ${(amount / 100).toFixed(2)}
        </div>
      );
    },
  },
  {
    accessorKey: "createdAt",
    header: "Date",
    cell: ({ row }) =>
      new Date(row.getValue("createdAt")).toLocaleDateString(),
  },
];

Step 3: Data Table Component

// components/data-table/DataTable.tsx
"use client";

import { useState } from "react";
import {
  useReactTable,
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  flexRender,
  type SortingState,
  type ColumnFiltersState,
  type VisibilityState,
  type RowSelectionState,
  type ColumnDef,
} from "@tanstack/react-table";

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
}

export function DataTable<TData, TValue>({
  columns,
  data,
}: DataTableProps<TData, TValue>) {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
  const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
  const [globalFilter, setGlobalFilter] = useState("");

  const table = useReactTable({
    data,
    columns,
    state: {
      sorting,
      columnFilters,
      columnVisibility,
      rowSelection,
      globalFilter,
    },
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    onColumnVisibilityChange: setColumnVisibility,
    onRowSelectionChange: setRowSelection,
    onGlobalFilterChange: setGlobalFilter,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    enableRowSelection: true,
  });

  return (
    <div className="space-y-4">
      {/* Toolbar */}
      <div className="flex items-center gap-4">
        <input
          value={globalFilter}
          onChange={(e) => setGlobalFilter(e.target.value)}
          placeholder="Search all columns..."
          className="max-w-sm rounded-lg border px-3 py-2 text-sm"
        />
        <StatusFilter table={table} />
        <ColumnToggle table={table} />
      </div>

      {/* Table */}
      <div className="overflow-hidden rounded-lg border">
        <table className="w-full text-sm">
          <thead className="border-b bg-gray-50">
            {table.getHeaderGroups().map((headerGroup) => (
              <tr key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <th
                    key={header.id}
                    className="px-4 py-3 text-left font-medium text-gray-500"
                  >
                    {header.isPlaceholder ? null : (
                      <button
                        className={`flex items-center gap-1 ${
                          header.column.getCanSort() ? "cursor-pointer select-none" : ""
                        }`}
                        onClick={header.column.getToggleSortingHandler()}
                      >
                        {flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                        {header.column.getIsSorted() === "asc" && " ^"}
                        {header.column.getIsSorted() === "desc" && " v"}
                      </button>
                    )}
                  </th>
                ))}
              </tr>
            ))}
          </thead>
          <tbody className="divide-y">
            {table.getRowModel().rows.length > 0 ? (
              table.getRowModel().rows.map((row) => (
                <tr
                  key={row.id}
                  className={row.getIsSelected() ? "bg-blue-50" : "hover:bg-gray-50"}
                >
                  {row.getVisibleCells().map((cell) => (
                    <td key={cell.id} className="px-4 py-3">
                      {flexRender(
                        cell.column.columnDef.cell,
                        cell.getContext()
                      )}
                    </td>
                  ))}
                </tr>
              ))
            ) : (
              <tr>
                <td
                  colSpan={columns.length}
                  className="px-4 py-8 text-center text-gray-500"
                >
                  No results found.
                </td>
              </tr>
            )}
          </tbody>
        </table>
      </div>

      {/* Pagination */}
      <div className="flex items-center justify-between">
        <p className="text-sm text-gray-500">
          {table.getFilteredSelectedRowModel().rows.length} of{" "}
          {table.getFilteredRowModel().rows.length} row(s) selected
        </p>
        <div className="flex items-center gap-2">
          <button
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
            className="rounded-lg border px-3 py-1.5 text-sm disabled:opacity-50"
          >
            Previous
          </button>
          <span className="text-sm text-gray-600">
            Page {table.getState().pagination.pageIndex + 1} of{" "}
            {table.getPageCount()}
          </span>
          <button
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
            className="rounded-lg border px-3 py-1.5 text-sm disabled:opacity-50"
          >
            Next
          </button>
        </div>
      </div>
    </div>
  );
}

Step 4: Status Filter

// components/data-table/StatusFilter.tsx
"use client";

import { Table } from "@tanstack/react-table";

const statuses = [
  { value: "pending", label: "Pending" },
  { value: "processing", label: "Processing" },
  { value: "success", label: "Success" },
  { value: "failed", label: "Failed" },
];

export function StatusFilter<TData>({ table }: { table: Table<TData> }) {
  const column = table.getColumn("status");
  const selectedValues = (column?.getFilterValue() as string[]) ?? [];

  function toggleStatus(value: string) {
    const newValues = selectedValues.includes(value)
      ? selectedValues.filter((v) => v !== value)
      : [...selectedValues, value];
    column?.setFilterValue(newValues.length > 0 ? newValues : undefined);
  }

  return (
    <div className="flex gap-1">
      {statuses.map((status) => (
        <button
          key={status.value}
          onClick={() => toggleStatus(status.value)}
          className={`rounded-full px-3 py-1 text-xs font-medium transition ${
            selectedValues.includes(status.value)
              ? "bg-gray-900 text-white"
              : "bg-gray-100 text-gray-600 hover:bg-gray-200"
          }`}
        >
          {status.label}
        </button>
      ))}
    </div>
  );
}

Step 5: Column Visibility Toggle

// components/data-table/ColumnToggle.tsx
"use client";

import { useState } from "react";
import { Table } from "@tanstack/react-table";

export function ColumnToggle<TData>({ table }: { table: Table<TData> }) {
  const [open, setOpen] = useState(false);

  return (
    <div className="relative ml-auto">
      <button
        onClick={() => setOpen(!open)}
        className="rounded-lg border px-3 py-1.5 text-sm"
      >
        Columns
      </button>
      {open && (
        <div className="absolute right-0 top-full z-10 mt-1 w-48 rounded-lg border bg-white p-2 shadow-lg">
          {table
            .getAllColumns()
            .filter((column) => column.getCanHide())
            .map((column) => (
              <label
                key={column.id}
                className="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50"
              >
                <input
                  type="checkbox"
                  checked={column.getIsVisible()}
                  onChange={column.getToggleVisibilityHandler()}
                />
                {column.id}
              </label>
            ))}
        </div>
      )}
    </div>
  );
}

Step 6: Usage

// app/payments/page.tsx
import { DataTable } from "@/components/data-table/DataTable";
import { columns, Payment } from "@/components/data-table/columns";

async function getPayments(): Promise<Payment[]> {
  // Fetch from your database
  return [];
}

export default async function PaymentsPage() {
  const payments = await getPayments();

  return (
    <div className="container py-10">
      <h1 className="mb-6 text-2xl font-bold">Payments</h1>
      <DataTable columns={columns} data={payments} />
    </div>
  );
}

Need Custom Admin Dashboards?

We build data-rich dashboards with advanced tables, charts, and real-time updates. Contact us to discuss your project.

data tableTanStack TablesortingfilteringReacttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles