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.
📖 Want the complete guide with more examples and advanced patterns? Check out the full article on my blog for an in-depth tutorial with additional code examples, troubleshooting tips, and real-world use cases.
What is TanStack Table?
TanStack Table (formerly React Table) is a headless UI library for building powerful data tables in React. It provides:
- Sorting - Click column headers to sort data
- Filtering - Global and column-specific filters
- Pagination - Navigate through pages of data
- Row Selection - Select single or multiple rows
- Column Resizing - Resize columns dynamically
- TypeScript Support - Full type safety out of the box
- Headless Design - Complete control over UI rendering
Installation
Install TanStack Table:
npm install @tanstack/react-table
For TypeScript projects, types are included automatically.
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:
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.
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:
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());
},
},
// 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.
Using the Table Component
Here's how to use the table component in your application:
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>
);
}
Key Features Explained
Sorting
Enable sorting by setting enableSorting: true in column definitions:
{
accessorKey: "name",
enableSorting: true,
header: ({ column }) => (
<button onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
Name {column.getIsSorted() === "asc" ? "▲" : column.getIsSorted() === "desc" ? "▼" : "↕"}
</button>
),
}
Filtering
Enable filtering with enableColumnFilter: true and custom filter functions:
{
accessorKey: "name",
enableColumnFilter: true,
filterFn: (row, id, value) => {
const cellValue = row.getValue(id) as string;
return cellValue.toLowerCase().includes(value.toLowerCase());
},
}
Pagination
Pagination is handled automatically with getPaginationRowModel():
const table = useReactTable({
// ... other config
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
});
Row Selection
Enable row selection with enableRowSelection: true:
const table = useReactTable({
// ... other config
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
});
TypeScript Support
TanStack Table has excellent TypeScript support. Type your data and columns:
interface Product {
id: string;
name: string;
price: number;
stock: number;
categoryName: string;
}
const columns: ColumnDef<Product>[] = [
{
accessorKey: "name",
header: "Name",
},
// ... other columns
];
// Use with typed data
<TanstackTable<Product>
columns={columns}
data={products}
/>
Best Practices
- Use TypeScript generics - Make your table component reusable with any data type
- Handle loading and error states - Provide feedback to users
- Implement empty states - Show helpful messages when no data is available
- Use custom filter functions - For complex filtering logic
- Enable row selection - For bulk operations
- Customize cell rendering - Match your design system
- Use pagination - For large datasets
- Implement global search - For quick data discovery
- Add sorting indicators - Visual feedback for sorted columns
-
Optimize performance - Use
manualPaginationfor server-side pagination
Server-Side Pagination
For large datasets, use server-side pagination:
const table = useReactTable({
data,
columns,
manualPagination: true, // Enable server-side pagination
pageCount: totalPages, // Total number of pages from server
state: {
pagination: {
pageIndex: currentPage,
pageSize: pageSize,
},
},
onPaginationChange: (updater) => {
const newPagination = typeof updater === 'function'
? updater(table.getState().pagination)
: updater;
// Fetch data for new page
fetchData(newPagination.pageIndex, newPagination.pageSize);
},
});
Resources and Further Reading
- 📚 Full TanStack Table Guide - Complete tutorial with advanced examples, troubleshooting, and best practices
- React Hook Form with Zod - Form validation for table filters
- Redux Toolkit RTK Query - Data fetching for table data
- TanStack Table Documentation - Official TanStack Table docs
- TanStack Table Examples - Official examples
- React TypeScript Guide - TypeScript with React
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.
Key Takeaways:
- TanStack Table is a headless table library with full control over UI
- TypeScript support - Full type safety out of the box
- Sorting, filtering, pagination - All handled automatically
- Row selection - Built-in support for bulk operations
- Customizable - Complete control over rendering and styling
- Production-ready - Handles loading, error, and empty states
- Server-side pagination - For large datasets
- Reusable components - TypeScript generics for any data type
Whether you're building a simple data table or a complex inventory management system, TanStack Table provides the foundation you need. It handles all the complex logic while giving you complete control over the UI.
What's your experience with TanStack Table? Share your tips and tricks in the comments below! 🚀
💡 Looking for more details? This is a condensed version of my comprehensive guide. Read the full article on my blog for additional examples, advanced patterns, troubleshooting tips, and more in-depth explanations.
If you found this guide helpful, consider checking out my other articles on React development and frontend development best practices.
Top comments (0)