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-tableComplete 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.
Related Articles
React Hook Form with Zod Validation: Complete Guide
Learn how to implement form validation in React with TypeScript and Zod schemas.
Redux Toolkit RTK Query: Complete Guide for React State Management
Learn how to use RTK Query for API data fetching and state management in React.
TypeScript with React: Best Practices and Patterns
Learn TypeScript best practices for React development with type definitions and interfaces.
React Router Setup: Complete Guide for React Applications
Learn how to set up React Router DOM with routes, navigation, and protected routes.