Back to Blog
React14 min read

TanStack Table in React – Complete Guide

Learn TanStack Table implementation in React with TypeScript. Build data tables with sorting, filtering, pagination, and row selection. Step-by-step guide with examples.

Building data tables in React is one of those tasks that seems simple until you actually try to do it right. I've built tables from scratch multiple times, and each time I'd implement sorting, then filtering, then pagination, then realize I need row selection, column resizing, and virtualization for large datasets. That's when I discovered TanStack Table (formerly React Table), and it changed everything.

TanStack Table is a headless table library, which means it handles all the logic (sorting, filtering, pagination, etc.) but doesn't impose any styling. You get full control over how your table looks while benefiting from battle-tested table logic. It's perfect for building custom table components that match your design system.

In this guide, I'll walk you through building a production-ready data table component using TanStack Table. We'll implement sorting (click column headers to sort), filtering (search across columns), pagination (navigate through pages of data), row selection (select multiple rows), and TypeScript support for type safety. I'll also share some performance optimizations I've learned for handling large datasets efficiently.

Installation

npm install @tanstack/react-table

Complete Table Component with All Features

Here's a production-ready table component that includes all the features you'll need: loading states, error handling, empty states, global search, column filters, pagination controls, and row selection. This is the exact pattern I use in my applications:

import { useState } from "react"; import { useReactTable, getCoreRowModel, getFilteredRowModel, getSortedRowModel, getPaginationRowModel, flexRender, type ColumnDef, } from "@tanstack/react-table"; interface TanstackTableProps<T> { columns: ColumnDef<T>[]; data: T[]; isLoading?: boolean; isError?: boolean; emptyMessage?: string; globalFilter?: string; onGlobalFilterChange?: (value: string) => void; } function TanstackTable<T>({ columns, data, isLoading = false, isError = false, emptyMessage = "No data available", globalFilter, onGlobalFilterChange, }: TanstackTableProps<T>) { const [rowSelection, setRowSelection] = useState({}); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); const [columnFilters, setColumnFilters] = useState([]); const [globalFilterValue, setGlobalFilterValue] = useState(globalFilter || ""); const table = useReactTable({ data, columns, state: { columnFilters, globalFilter: globalFilterValue, pagination, rowSelection, }, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), getPaginationRowModel: getPaginationRowModel(), onColumnFiltersChange: setColumnFilters, onGlobalFilterChange: (value) => { setGlobalFilterValue(value); onGlobalFilterChange?.(value); }, onPaginationChange: setPagination, onRowSelectionChange: setRowSelection, enableRowSelection: true, enableGlobalFilter: true, manualPagination: false, // Set to true if pagination is server-side }); // Handle global filter input const handleGlobalFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => { const value = e.target.value; setGlobalFilterValue(value); table.setGlobalFilter(value); onGlobalFilterChange?.(value); }; // Get selected rows const selectedRows = table.getFilteredSelectedRowModel().rows; const selectedRowCount = selectedRows.length; if (isLoading) { return ( <div className="flex items-center justify-center p-8"> <div className="text-center"> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div> <p className="text-gray-600">Loading data...</p> </div> </div> ); } if (isError) { return ( <div className="flex items-center justify-center p-8"> <div className="text-center text-red-600"> <p className="text-lg font-semibold mb-2">Error loading data</p> <p className="text-sm">Please try again later</p> </div> </div> ); } return ( <div className="space-y-4"> {/* Global Search and Selected Rows Info */} <div className="flex items-center justify-between gap-4"> <div className="flex-1 max-w-md"> <input type="text" value={globalFilterValue} onChange={handleGlobalFilterChange} placeholder="Search all columns..." className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" /> </div> {selectedRowCount > 0 && ( <div className="flex items-center gap-2 text-sm text-blue-600"> <span>{selectedRowCount} row(s) selected</span> <button onClick={() => setRowSelection({})} className="text-red-600 hover:text-red-700" > Clear selection </button> </div> )} </div> {/* Table */} <div className="overflow-x-auto border border-gray-200 rounded-lg"> <table className="min-w-full divide-y divide-gray-200"> <thead className="bg-gray-50"> {table.getHeaderGroups().map((headerGroup) => ( <tr key={headerGroup.id}> {headerGroup.headers.map((header) => ( <th key={header.id} className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" style={{ width: header.getSize() }} > {header.isPlaceholder ? null : ( <div className="flex items-center gap-2"> {flexRender(header.column.columnDef.header, header.getContext())} {header.column.getCanSort() && ( <button onClick={header.column.getToggleSortingHandler()} className="hover:text-blue-600" > {header.column.getIsSorted() === "asc" ? " ▲" : header.column.getIsSorted() === "desc" ? " ▼" : " ↕"} </button> )} </div> )} </th> ))} </tr> ))} </thead> <tbody className="bg-white divide-y divide-gray-200"> {table.getRowModel().rows.length === 0 ? ( <tr> <td colSpan={columns.length} className="px-6 py-8 text-center text-gray-500" > {emptyMessage} </td> </tr> ) : ( table.getRowModel().rows.map((row) => ( <tr key={row.id} className={`hover:bg-gray-50 ${row.getIsSelected() ? "bg-blue-50" : ""}`} > {row.getVisibleCells().map((cell) => ( <td key={cell.id} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900" > {flexRender(cell.column.columnDef.cell, cell.getContext())} </td> ))} </tr> )) )} </tbody> </table> </div> {/* Pagination Controls */} <div className="flex items-center justify-between"> <div className="flex items-center gap-2"> <span className="text-sm text-gray-700">Show</span> <select value={table.getState().pagination.pageSize} onChange={(e) => { table.setPageSize(Number(e.target.value)); }} className="px-3 py-1 border border-gray-300 rounded text-sm" > {[10, 20, 30, 50, 100].map((pageSize) => ( <option key={pageSize} value={pageSize}> {pageSize} </option> ))} </select> <span className="text-sm text-gray-700">entries</span> </div> <div className="flex items-center gap-2"> <span className="text-sm text-gray-700"> Showing {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} to{" "} {Math.min( (table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize, table.getFilteredRowModel().rows.length )}{" "} of {table.getFilteredRowModel().rows.length} entries </span> </div> <div className="flex items-center gap-2"> <button onClick={() => table.firstPage()} disabled={!table.getCanPreviousPage()} className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50" > {"<<"} </button> <button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50" > {"<"} </button> <span className="text-sm text-gray-700"> Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} </span> <button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50" > {">"} </button> <button onClick={() => table.lastPage()} disabled={!table.getCanNextPage()} className="px-3 py-1 border border-gray-300 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50" > {">>"} </button> </div> </div> </div> ); } export default TanstackTable;

This complete component handles all the edge cases: loading states, error states, empty data, row selection feedback, and comprehensive pagination controls. The key is using TanStack Table's state management hooks and rendering functions to build a fully functional table with minimal code. Notice how I use TypeScript generics to make the component reusable with any data type.

Defining Columns with Advanced Features

Column definitions are where TanStack Table really shines. You can customize sorting, filtering, cell rendering, and even add custom actions. Here's a comprehensive example I use in production:

import type { ColumnDef } from "@tanstack/react-table"; import type { Product } from "../../types"; const columns: ColumnDef<Product>[] = [ // Row selection column { id: "select", header: ({ table }) => ( <IndeterminateCheckbox checked={table.getIsAllRowsSelected()} indeterminate={table.getIsSomeRowsSelected()} onChange={table.getToggleAllRowsSelectedHandler()} /> ), cell: ({ row }) => ( <IndeterminateCheckbox checked={row.getIsSelected()} disabled={!row.getCanSelect()} indeterminate={row.getIsSomeSelected()} onChange={row.getToggleSelectedHandler()} /> ), size: 50, enableSorting: false, enableColumnFilter: false, }, // Product Name with sorting and filtering { accessorKey: "name", header: ({ column }) => { return ( <button onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="flex items-center gap-2 hover:text-blue-600" > Product Name {column.getIsSorted() === "asc" ? " ▲" : column.getIsSorted() === "desc" ? " ▼" : " ↕"} </button> ); }, cell: (info) => { const value = info.getValue() as string; return <span className="font-medium">{value}</span>; }, enableSorting: true, enableColumnFilter: true, filterFn: (row, id, value) => { const cellValue = row.getValue(id) as string; return cellValue.toLowerCase().includes(value.toLowerCase()); }, }, // SKU column { accessorKey: "sku", header: "SKU", cell: (info) => { const sku = info.getValue() as string; return ( <span className="font-mono text-sm text-gray-600"> {sku || "N/A"} </span> ); }, enableSorting: true, enableColumnFilter: true, }, // Category with badge styling { accessorKey: "categoryName", header: "Category", cell: (info) => { const category = info.getValue() as string; return ( <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"> {category || "Uncategorized"} </span> ); }, enableSorting: true, enableColumnFilter: true, }, // Price with currency formatting and sorting { accessorKey: "price", header: ({ column }) => { return ( <button onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} className="flex items-center gap-2 hover:text-blue-600" > Price {column.getIsSorted() === "asc" ? " ▲" : column.getIsSorted() === "desc" ? " ▼" : " ↕"} </button> ); }, cell: (info) => { const price = info.getValue() as number; return ( <span className="font-semibold text-green-600"> ${price.toFixed(2)} </span> ); }, enableSorting: true, sortingFn: (rowA, rowB) => { const priceA = rowA.getValue("price") as number; const priceB = rowB.getValue("price") as number; return priceA - priceB; }, }, // Stock with conditional styling { accessorKey: "stock", header: "Stock", cell: (info) => { const stock = info.getValue() as number; const minStock = info.row.original.minStock || 0; const isLowStock = stock <= minStock; return ( <div className="flex items-center gap-2"> <span className={`font-semibold ${isLowStock ? "text-red-600" : "text-gray-900"}`}> {stock} </span> {isLowStock && ( <span className="text-xs text-red-600 bg-red-100 px-2 py-0.5 rounded"> Low Stock </span> )} </div> ); }, enableSorting: true, enableColumnFilter: true, filterFn: (row, id, value) => { const stock = row.getValue(id) as number; const minStock = row.original.minStock || 0; if (value === "low") return stock <= minStock; if (value === "in-stock") return stock > minStock; return true; }, }, // Actions column { id: "actions", header: "Actions", cell: ({ row }) => { const product = row.original; return ( <div className="flex items-center gap-2"> <button onClick={() => handleEdit(product.id)} className="px-3 py-1 text-sm bg-blue-500 text-white rounded hover:bg-blue-600" > Edit </button> <button onClick={() => handleDelete(product.id)} className="px-3 py-1 text-sm bg-red-500 text-white rounded hover:bg-red-600" > Delete </button> </div> ); }, enableSorting: false, enableColumnFilter: false, }, ]; // Helper component for indeterminate checkbox function IndeterminateCheckbox({ checked, indeterminate, onChange, disabled, }: { checked: boolean; indeterminate: boolean; onChange: () => void; disabled?: boolean; }) { return ( <input type="checkbox" checked={checked} ref={(el) => { if (el) el.indeterminate = indeterminate; }} onChange={onChange} disabled={disabled} className="cursor-pointer" /> ); }

This column definition shows several advanced features: custom sorting indicators, conditional cell styling (low stock warnings), custom filter functions, action buttons, and row selection. The key is that each column can have its own sorting, filtering, and rendering logic, giving you complete control over how data is displayed and interacted with.

Using the Table Component

import TanstackTable from "../../components/TanstackTable/TanstackTable"; import { useGetProductsQuery } from "../../state/products/productSlice"; function Products() { const [globalFilter, setGlobalFilter] = useState(""); const { data, isLoading, isError } = useGetProductsQuery({}); const products = data?.data || []; return ( <div> <input value={globalFilter ?? ""} onChange={(e) => setGlobalFilter(String(e.target.value))} placeholder="Search products..." className="px-4 py-2 border rounded-lg" /> <TanstackTable columns={columns} data={products} isLoading={isLoading} isError={isError} globalFilter={globalFilter} emptyMessage="No products found." /> </div> ); }

Row Selection

Adding row selection with checkboxes:

{ id: "select", header: ({ table }) => ( <IndeterminateCheckbox checked={table.getIsAllRowsSelected()} indeterminate={table.getIsSomeRowsSelected()} onChange={table.getToggleAllRowsSelectedHandler()} /> ), cell: ({ row }) => ( <IndeterminateCheckbox checked={row.getIsSelected()} disabled={!row.getCanSelect()} indeterminate={row.getIsSomeSelected()} onChange={row.getToggleSelectedHandler()} /> ), size: 50, },

Conclusion

TanStack Table provides a flexible, performant solution for building complex data tables in React. With features like sorting, filtering, pagination, and row selection, it's perfect for inventory management systems and data-heavy applications. The headless design allows for complete customization while providing powerful features out of the box.