Back to Blog

TanStack Table v8: Sorting, Filtering, Pagination, Virtualization, and TypeScript

Build production data tables with TanStack Table v8: type-safe column definitions, client-side sorting and filtering, server-side pagination, row virtualization for large datasets, and editable cells.

Viprasol Tech Team
December 21, 2026
13 min read

TanStack Table v8 is headless โ€” it gives you state management and logic for sorting, filtering, pagination, grouping, and virtualization, but zero UI. You render the table yourself with whatever CSS framework you use. The result is total control over the look and feel, with none of the limitations of bundled UI table components.

This post covers the practical patterns: type-safe column definitions, client-side and server-side data modes, column sorting and filtering, row selection, and virtualization with TanStack Virtual for tables with 10,000+ rows.

Installation

npm install @tanstack/react-table @tanstack/react-virtual

1. Column Definitions and Basic Table

// src/components/tables/OrdersTable.tsx
'use client';

import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  flexRender,
  createColumnHelper,
  type SortingState,
  type ColumnFiltersState,
} from '@tanstack/react-table';
import { useState } from 'react';
import { ArrowUpDown, ChevronUp, ChevronDown } from 'lucide-react';

interface Order {
  id: string;
  customerName: string;
  email: string;
  status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
  totalCents: number;
  createdAt: Date;
}

// Column helper provides type-safe column definitions
const columnHelper = createColumnHelper<Order>();

const columns = [
  columnHelper.accessor('id', {
    header: 'Order ID',
    cell: (info) => (
      <span className="font-mono text-xs text-gray-500">
        #{info.getValue().slice(-6).toUpperCase()}
      </span>
    ),
    enableSorting: false,
  }),

  columnHelper.accessor('customerName', {
    header: ({ column }) => (
      <button
        className="flex items-center gap-1 font-medium"
        onClick={() => column.toggleSorting()}
      >
        Customer
        {column.getIsSorted() === 'asc' ? (
          <ChevronUp className="h-3 w-3" />
        ) : column.getIsSorted() === 'desc' ? (
          <ChevronDown className="h-3 w-3" />
        ) : (
          <ArrowUpDown className="h-3 w-3 text-gray-400" />
        )}
      </button>
    ),
  }),

  columnHelper.accessor('email', {
    header: 'Email',
    cell: (info) => <span className="text-gray-500 text-sm">{info.getValue()}</span>,
  }),

  columnHelper.accessor('status', {
    header: 'Status',
    cell: (info) => {
      const status = info.getValue();
      const colors: Record<Order['status'], string> = {
        pending:    'bg-yellow-100 text-yellow-700',
        processing: 'bg-blue-100 text-blue-700',
        shipped:    'bg-purple-100 text-purple-700',
        delivered:  'bg-green-100 text-green-700',
        cancelled:  'bg-red-100 text-red-700',
      };
      return (
        <span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${colors[status]}`}>
          {status.charAt(0).toUpperCase() + status.slice(1)}
        </span>
      );
    },
    filterFn: 'equals',
  }),

  columnHelper.accessor('totalCents', {
    header: ({ column }) => (
      <button className="flex items-center gap-1 font-medium" onClick={() => column.toggleSorting()}>
        Total
        {column.getIsSorted() === 'asc' ? <ChevronUp className="h-3 w-3" /> :
         column.getIsSorted() === 'desc' ? <ChevronDown className="h-3 w-3" /> :
         <ArrowUpDown className="h-3 w-3 text-gray-400" />}
      </button>
    ),
    cell: (info) =>
      new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
        info.getValue() / 100
      ),
  }),

  columnHelper.accessor('createdAt', {
    header: 'Date',
    cell: (info) =>
      new Date(info.getValue()).toLocaleDateString('en-US', {
        month: 'short', day: 'numeric', year: 'numeric',
      }),
    sortingFn: 'datetime',
  }),
];

export function OrdersTable({ data }: { data: Order[] }) {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
  const [globalFilter, setGlobalFilter] = useState('');

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

  return (
    <div className="space-y-4">
      {/* Global search */}
      <div className="flex items-center gap-3">
        <input
          value={globalFilter}
          onChange={(e) => setGlobalFilter(e.target.value)}
          placeholder="Search orders..."
          className="h-9 w-64 rounded-lg border border-gray-300 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        {/* Status filter */}
        <select
          value={(table.getColumn('status')?.getFilterValue() as string) ?? ''}
          onChange={(e) =>
            table.getColumn('status')?.setFilterValue(e.target.value || undefined)
          }
          className="h-9 rounded-lg border border-gray-300 px-3 text-sm"
        >
          <option value="">All statuses</option>
          <option value="pending">Pending</option>
          <option value="processing">Processing</option>
          <option value="shipped">Shipped</option>
          <option value="delivered">Delivered</option>
          <option value="cancelled">Cancelled</option>
        </select>
      </div>

      {/* Table */}
      <div className="overflow-x-auto rounded-lg border border-gray-200">
        <table className="w-full text-sm">
          <thead className="bg-gray-50 border-b border-gray-200">
            {table.getHeaderGroups().map((headerGroup) => (
              <tr key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <th key={header.id} className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                    {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
                  </th>
                ))}
              </tr>
            ))}
          </thead>
          <tbody className="divide-y divide-gray-100">
            {table.getRowModel().rows.map((row) => (
              <tr key={row.id} className="hover:bg-gray-50 transition-colors">
                {row.getVisibleCells().map((cell) => (
                  <td key={cell.id} className="px-4 py-3 text-gray-700">
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      {/* Pagination */}
      <div className="flex items-center justify-between">
        <p className="text-sm text-gray-500">
          {table.getFilteredRowModel().rows.length} results
          {table.getPageCount() > 1 && ` ยท Page ${table.getState().pagination.pageIndex + 1} of ${table.getPageCount()}`}
        </p>
        <div className="flex items-center gap-2">
          <button
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
            className="rounded border px-3 py-1 text-sm disabled:opacity-40"
          >
            Previous
          </button>
          <button
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
            className="rounded border px-3 py-1 text-sm disabled:opacity-40"
          >
            Next
          </button>
          <select
            value={table.getState().pagination.pageSize}
            onChange={(e) => table.setPageSize(Number(e.target.value))}
            className="rounded border px-2 py-1 text-sm"
          >
            {[10, 20, 50, 100].map((s) => <option key={s} value={s}>{s} per page</option>)}
          </select>
        </div>
      </div>
    </div>
  );
}

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

2. Server-Side Pagination and Sorting

// src/components/tables/ServerSideOrdersTable.tsx
'use client';

import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';

interface FetchOrdersParams {
  page: number;
  pageSize: number;
  sortBy: string;
  sortDir: 'asc' | 'desc';
  search: string;
  status: string;
}

async function fetchOrders(params: FetchOrdersParams) {
  const query = new URLSearchParams({
    page: params.page.toString(),
    pageSize: params.pageSize.toString(),
    sortBy: params.sortBy,
    sortDir: params.sortDir,
    ...(params.search && { search: params.search }),
    ...(params.status && { status: params.status }),
  });

  return fetch(`/api/orders?${query}`).then((r) => r.json()) as Promise<{
    data: Order[];
    total: number;
    page: number;
    pageCount: number;
  }>;
}

export function ServerSideOrdersTable() {
  const [sorting, setSorting] = useState<SortingState>([{ id: 'createdAt', desc: true }]);
  const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20 });
  const [globalFilter, setGlobalFilter] = useState('');
  const [statusFilter, setStatusFilter] = useState('');

  const { data, isLoading, isFetching } = useQuery({
    queryKey: ['orders', { sorting, pagination, globalFilter, statusFilter }],
    queryFn: () => fetchOrders({
      page: pagination.pageIndex + 1,
      pageSize: pagination.pageSize,
      sortBy: sorting[0]?.id ?? 'createdAt',
      sortDir: sorting[0]?.desc ? 'desc' : 'asc',
      search: globalFilter,
      status: statusFilter,
    }),
    placeholderData: (prev) => prev, // Keep previous data while fetching
  });

  const table = useReactTable({
    data: data?.data ?? [],
    columns,
    state: { sorting, pagination },
    onSortingChange: setSorting,
    onPaginationChange: setPagination,
    getCoreRowModel: getCoreRowModel(),
    manualSorting: true,        // โ† Server handles sorting
    manualPagination: true,     // โ† Server handles pagination
    pageCount: data?.pageCount ?? -1,
    rowCount: data?.total,
  });

  return (
    <div className={isFetching ? 'opacity-75 transition-opacity' : ''}>
      {/* ... same table UI ... */}
    </div>
  );
}

3. Row Virtualization for Large Datasets

// For 10,000+ rows: virtualize with @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';

export function VirtualizedTable({ data }: { data: Order[] }) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    // Don't paginate โ€” render all (virtually)
  });

  const rows = table.getRowModel().rows;
  const tableContainerRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => tableContainerRef.current,
    estimateSize: () => 48,     // Row height in px
    overscan: 10,               // Render 10 extra rows outside viewport
  });

  const virtualRows = virtualizer.getVirtualItems();
  const totalSize = virtualizer.getTotalSize();

  // Padding to simulate full list height
  const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
  const paddingBottom = virtualRows.length > 0
    ? totalSize - (virtualRows[virtualRows.length - 1].end)
    : 0;

  return (
    <div
      ref={tableContainerRef}
      className="overflow-auto rounded-lg border border-gray-200"
      style={{ height: '600px' }}    // Fixed height required for virtualization
    >
      <table className="w-full text-sm">
        <thead className="sticky top-0 bg-gray-50 z-10 border-b border-gray-200">
          {table.getHeaderGroups().map((hg) => (
            <tr key={hg.id}>
              {hg.headers.map((header) => (
                <th key={header.id} className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
                  {flexRender(header.column.columnDef.header, header.getContext())}
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {paddingTop > 0 && <tr style={{ height: paddingTop }} />}
          {virtualRows.map((virtualRow) => {
            const row = rows[virtualRow.index];
            return (
              <tr key={row.id} className="border-b border-gray-50 hover:bg-gray-50">
                {row.getVisibleCells().map((cell) => (
                  <td key={cell.id} className="px-4 py-3 text-gray-700">
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </td>
                ))}
              </tr>
            );
          })}
          {paddingBottom > 0 && <tr style={{ height: paddingBottom }} />}
        </tbody>
      </table>
    </div>
  );
}

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

See Also


Working With Viprasol

Building a data-heavy admin dashboard or analytics UI that needs sortable, filterable, paginated tables with tens of thousands of rows? We implement TanStack Table with the right combination of client-side and server-side modes, row virtualization for performance, and TypeScript type safety โ€” so your tables stay fast at any scale.

Talk to our team โ†’ | See our web development services โ†’

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.