DEV Community

Cover image for TanStack Table Implementation in React: Complete Guide for Data Tables
Md. Maruf Rahman
Md. Maruf Rahman

Posted on • Originally published at marufrahman.live

TanStack Table Implementation in React: Complete Guide for Data Tables

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  ),
}
Enter fullscreen mode Exit fullscreen mode

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());
  },
}
Enter fullscreen mode Exit fullscreen mode

Pagination

Pagination is handled automatically with getPaginationRowModel():

const table = useReactTable({
  // ... other config
  getPaginationRowModel: getPaginationRowModel(),
  onPaginationChange: setPagination,
});
Enter fullscreen mode Exit fullscreen mode

Row Selection

Enable row selection with enableRowSelection: true:

const table = useReactTable({
  // ... other config
  enableRowSelection: true,
  onRowSelectionChange: setRowSelection,
});
Enter fullscreen mode Exit fullscreen mode

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}
/>
Enter fullscreen mode Exit fullscreen mode

Best Practices

  1. Use TypeScript generics - Make your table component reusable with any data type
  2. Handle loading and error states - Provide feedback to users
  3. Implement empty states - Show helpful messages when no data is available
  4. Use custom filter functions - For complex filtering logic
  5. Enable row selection - For bulk operations
  6. Customize cell rendering - Match your design system
  7. Use pagination - For large datasets
  8. Implement global search - For quick data discovery
  9. Add sorting indicators - Visual feedback for sorted columns
  10. Optimize performance - Use manualPagination for 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);
  },
});
Enter fullscreen mode Exit fullscreen mode

Resources and Further Reading

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)