Skip to content

Building Tables and Data Grids

Aman Kumar Singh8 min read
Part 38 of 50From the React & Next.js Complete Guide series
Building Tables and Data Grids — article by Aman Kumar Singh

In Charts and Data Visualization in React, I looked at how to render aggregate data: totals, trends, distributions. Tables are the other half of that story. Once a user needs to see individual rows, filter them, sort them, and act on them one at a time, a chart stops being useful and a table takes over. Most SaaS products spend more engineering hours on their tables than on any single chart, because tables are where support tickets, admin panels, and billing history actually live.

This is part of the React & Next.js Complete Guide series, which builds up from React Fundamentals for Professionals toward production Next.js apps. Tables also happen to be where a lot of teams either over-engineer early (reaching for a full data grid library on day one) or under-engineer for too long (hand-rolling sort and pagination logic that quietly breaks once a table gets a second consumer).

I want to walk through how to think about table complexity in stages, and where a headless library like TanStack Table earns its keep. Along the way, I'll cover the production pitfalls that show up once a table is backed by a real database instead of a fixture file.

Start with the HTML table you already know

Before reaching for a table library, it's worth building the simplest version by hand. Doing so clarifies what you actually need. A <table> with a <thead> and <tbody> handles column layout for free and gives you semantic structure and screen reader support without extra work. It also lets you defer sorting and filtering to the server, if that's where the truth already lives.

type Invoice = {
  id: string;
  customerName: string;
  amount: number;
  status: "paid" | "pending" | "overdue";
  dueDate: string;
};

function InvoiceTable({ invoices }: { invoices: Invoice[] }) {
  return (
    <table className="w-full text-sm">
      <thead>
        <tr className="border-b text-left text-gray-500">
          <th className="py-2">Customer</th>
          <th className="py-2">Amount</th>
          <th className="py-2">Status</th>
          <th className="py-2">Due date</th>
        </tr>
      </thead>
      <tbody>
        {invoices.map((invoice) => (
          <tr key={invoice.id} className="border-b">
            <td className="py-2">{invoice.customerName}</td>
            <td className="py-2">${invoice.amount.toFixed(2)}</td>
            <td className="py-2 capitalize">{invoice.status}</td>
            <td className="py-2">{invoice.dueDate}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

This is the right answer for a lot of internal tools: a settings page listing team members, a read-only audit log, a report that's rendered once and rarely re-sorted. Reaching for a table library here adds a dependency for a problem you don't have yet. The moment you need column sorting that updates without a page reload, per-column filters, row selection with bulk actions, or pagination that stays in sync with a data source, hand-rolled state starts to sprawl across multiple useState calls that all have to agree with each other. That's the signal to move to something purpose-built.

Reaching for TanStack Table when state gets tangled

TanStack Table (formerly React Table) is headless: it manages sorting, filtering, pagination, row selection, and column visibility as state, but renders nothing itself. You own the markup. That's the right tradeoff for a design system that already has its own table styles, because you're not fighting a component library's opinions about how a cell should look.

import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  getPaginationRowModel,
  flexRender,
  createColumnHelper,
} from "@tanstack/react-table";
import { useState } from "react";

type Invoice = {
  id: string;
  customerName: string;
  amount: number;
  status: "paid" | "pending" | "overdue";
  dueDate: string;
};

const columnHelper = createColumnHelper<Invoice>();

const columns = [
  columnHelper.accessor("customerName", {
    header: "Customer",
    cell: (info) => info.getValue(),
  }),
  columnHelper.accessor("amount", {
    header: "Amount",
    cell: (info) => `$${info.getValue().toFixed(2)}`,
  }),
  columnHelper.accessor("status", {
    header: "Status",
    cell: (info) => info.getValue(),
  }),
  columnHelper.accessor("dueDate", {
    header: "Due date",
  }),
];

function InvoiceGrid({ invoices }: { invoices: Invoice[] }) {
  const [sorting, setSorting] = useState([]);

  const table = useReactTable({
    data: invoices,
    columns,
    state: { sorting },
    onSortingChange: setSorting,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  });

  return (
    <table className="w-full text-sm">
      <thead>
        {table.getHeaderGroups().map((headerGroup) => (
          <tr key={headerGroup.id} className="border-b text-left text-gray-500">
            {headerGroup.headers.map((header) => (
              <th
                key={header.id}
                className="cursor-pointer py-2 select-none"
                onClick={header.column.getToggleSortingHandler()}
              >
                {flexRender(header.column.columnDef.header, header.getContext())}
                {{ asc: " ↑", desc: " ↓" }[header.column.getIsSorted() as string] ?? ""}
              </th>
            ))}
          </tr>
        ))}
      </thead>
      <tbody>
        {table.getRowModel().rows.map((row) => (
          <tr key={row.id} className="border-b">
            {row.getVisibleCells().map((cell) => (
              <td key={cell.id} className="py-2">
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

What you're buying here is a state machine for sorting, pagination, and filtering that's been tested against edge cases you'd otherwise hit one by one: multi-column sort priority, column visibility interacting with column widths, selection state surviving a page change. Typed column definitions also mean a renamed field breaks at compile time instead of silently rendering undefined in production.

The tradeoff is that TanStack Table gives you primitives, not a finished table. You still write every <th>, every sort indicator, every pagination button. If your team needs several tables shipped quickly and doesn't have strong opinions about table visuals, a batteries-included grid (MUI's DataGrid, AG Grid, or a shadcn/ui table recipe) gets you there faster. Choose the headless route when you have a design system to match or expect the table's behavior to diverge from any pre-built grid's defaults.

Client-side versus server-side: where should the data live

This is the decision that determines almost everything else about a table's architecture, and it's easy to get backwards. Client-side sorting and filtering work on the array you already have in memory. Server-side sorting and filtering send the current sort column, filter values, and page number to an API and re-fetch on every change.

Client-side is simpler and it's the right default when the full dataset is small enough to load once and unlikely to grow past a few thousand rows: a settings page listing team members, a list of API keys, a list of webhook endpoints. These are bounded by real-world limits, and client-side handling means zero network round-trips for sort and filter interactions.

Server-side is required once the dataset is large enough that loading it all up front is wasteful, or once the data needs to reflect other users' concurrent writes. An admin panel listing every customer, an invoice history spanning years, an audit log: these need pagination, sorting, and filtering pushed down to the database, because loading an entire table into the browser to sort ten rows out of a hundred thousand is both slow and pointless.

// Server-side query shape for a paginated, sorted, filtered invoice list
type InvoiceQuery = {
  page: number;
  pageSize: number;
  sortBy: "customerName" | "amount" | "dueDate";
  sortDir: "asc" | "desc";
  status?: "paid" | "pending" | "overdue";
};

async function getInvoices(query: InvoiceQuery) {
  const offset = (query.page - 1) * query.pageSize;

  return prisma.invoice.findMany({
    where: query.status ? { status: query.status } : undefined,
    orderBy: { [query.sortBy]: query.sortDir },
    skip: offset,
    take: query.pageSize,
  });
}

TanStack Table supports both modes through the same API surface: pass manualSorting: true and manualPagination: true, and instead of the built-in row models computing sort order from local data, you fetch fresh data whenever sorting or pagination state changes and feed it back in as data. The component code barely changes; what changes is who's responsible for the actual sort.

Pagination, virtualization, and the cost of getting it wrong

Once you're server-side, offset-based pagination (LIMIT and OFFSET in SQL) is the obvious first choice, and it's fine for admin tools where users browse a handful of pages. It breaks down at scale, for two reasons. OFFSET on a large table forces the database to scan and discard every row before it, so a later page is measurably slower than the first. And rows shifting between page loads can cause rows to appear twice or vanish, depending on sort direction. Cursor-based pagination, where you paginate from the last row's ID or timestamp instead of a numeric offset, avoids both problems. It's worth the extra API design work once a dataset grows past what a user would ever page through manually.

Virtualization is a separate axis entirely and solves a different problem: rendering thousands of DOM rows is what makes a browser tab slow, independent of how the data was fetched. Libraries like @tanstack/react-virtual render only the rows currently in the viewport, recycling DOM nodes as the user scrolls. Pagination limits how much data you fetch; virtualization limits how much you render. A table with genuinely large datasets often needs both, one to keep the API response small and one to keep scrolling smooth when a page size is itself large.

A pitfall worth watching for is building pagination controls in the UI while the API keeps returning the entire table regardless of the page parameter, because the backend half got deprioritized once the frontend "worked" against a small seed dataset. The bug doesn't surface until production data grows, and by then the load time has crept up gradually enough that nobody notices until a support ticket calls it out.

Row selection and bulk actions without fighting your own state

Bulk actions (select rows, then delete, export, or reassign them) are a common feature ask that's easy to implement inconsistently if each table reinvents selection state. TanStack Table's row selection handles the common cases directly: select-all that respects the current filter, indeterminate checkbox state, and selection that survives a client-side sort since sorting doesn't change row identity.

const table = useReactTable({
  data: invoices,
  columns,
  getCoreRowModel: getCoreRowModel(),
  getRowId: (row) => row.id,
  enableRowSelection: true,
  state: { rowSelection },
  onRowSelectionChange: setRowSelection,
});

const selectedInvoiceIds = Object.keys(rowSelection);

The detail that matters here is getRowId. Without it, TanStack Table defaults to using the row's array index as its identity, which works fine until the underlying data re-sorts or re-fetches: index 3 might now be a completely different invoice, but a selection keyed by index stays put on the wrong row. Always supply a stable getRowId keyed to a real identifier once selection is involved, even if everything works correctly in early testing against a small, unsorted dataset.

Key takeaways

  • Start with a plain HTML `<table>` for read-only or low-interaction lists. Reach for a table library only once sorting, filtering, or selection state gets tangled across multiple `useState` calls.
  • TanStack Table is a good fit when you have your own design system to match and want typed, tested state management without inherited visual opinions. A batteries-included grid is faster to ship when visual conformity matters less.
  • Decide client-side versus server-side sorting and filtering based on dataset size and whether other users are concurrently writing to the same data, not on which one is easier to wire up first.
  • Offset-based pagination is fine for small, human-browsed tables; move to cursor-based pagination once a table's dataset grows past what anyone would page through by hand.
  • Virtualization and pagination solve different problems: pagination limits what you fetch, virtualization limits what you render. Large tables often need both.
  • Always key row selection state with a stable `getRowId`, not array index, or selection silently attaches to the wrong row after a sort or refetch.

Frequently asked questions

Should I use AG Grid, MUI DataGrid, or TanStack Table for a new project?

AG Grid and MUI DataGrid are complete, opinionated grids with built-in styling, editing, and export features; they get you moving fastest if you don't need to match a custom design system. TanStack Table is headless and gives you full control over markup, which matters more once you have an established design system or need behavior the pre-built grids don't offer.

How do I handle a table with editable cells?

Treat each editable cell as its own small form: track a local draft value, save on blur or on a debounced change, and reconcile with the server response. TanStack Table doesn't manage cell editing state for you; you build it on top using the same `cell` render function, typically backed by a `useState` per row.

Is infinite scroll better than pagination for large tables?

Infinite scroll suits feeds where users browse sequentially. Tables usually benefit more from pagination or a "load more" pattern because users often need to reference a specific page, sort a column, or share a link to a specific view, all of which infinite scroll makes awkward.

What's the right page size for a server-side paginated table?

There's no universal number; it depends on row width and how expensive each row is to render. A reasonable starting point is a size that fills roughly one to two screens, adjusted based on how the table is actually used rather than guessed up front.

Do I need virtualization if my table is already paginated?

Not always. If your page size is modest, the DOM cost of rendering it is negligible and virtualization adds complexity you don't need. It earns its place when a single view genuinely needs to render hundreds or thousands of rows at once.

How should exports (CSV, Excel) interact with client-side versus server-side tables?

For client-side tables, exporting whatever's currently loaded in memory is straightforward since you already have the full dataset. For server-side tables, exporting "everything matching the current filter" usually requires a separate backend endpoint that streams or generates the export directly from the database, rather than trying to paginate through the UI's API to reconstruct it client-side.

Related articles

Mutations and Optimistic Updates — Aman Kumar Singh
Dark Mode Done Properly — Aman Kumar Singh
Keyboard Navigation and Focus Management — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.