---
title: "shadcn Table vs Data Table: When to Choose TanStack"
slug: "shadcn-table-vs-data-table-when-to-choose"
published: "2026-02-25"
updated: "2026-04-06"
categories:
  - "Next.js"
tags:
  - "shadcn table vs data table"
  - "TanStack Table"
  - "shadcn/ui Table"
  - "Next.js data table"
  - "sorting filtering pagination"
  - "react-table"
  - "headless UI TanStack"
  - "server components client components"
  - "tailwindcss"
  - "table performance"
  - "data table patterns"
  - "table state management"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "next.js"
  - "shadcn/ui"
  - "tanstack table (@tanstack/react-table)"
  - "react"
  - "typescript"
status: "stable"
llm-purpose: "shadcn Table vs Data Table — learn when to use shadcn/ui or TanStack Table in Next.js, optimize sorting, filtering and pagination, and avoid costly…"
llm-prereqs:
  - "Access to Next.js"
  - "Access to shadcn/ui"
  - "Access to TanStack Table (@tanstack/react-table)"
  - "Access to React"
  - "Access to TypeScript"
llm-outputs:
  - "Completed outcome: shadcn Table vs Data Table — learn when to use shadcn/ui or TanStack Table in Next.js, optimize sorting, filtering and pagination, and avoid costly…"
---

**Summary Triples**
- (shadcn/ui Table, is, a presentation component that renders standard HTML table markup and styling but provides no built-in sorting/filtering/pagination state)
- (Use shadcn/ui Table, when, you need a small, server-rendered or static display table without client-side sorting/filtering/pagination)
- (TanStack Table (@tanstack/react-table), is, a headless table engine that provides column/row models and rich state management for sorting, filtering, pagination, grouping, virtualization, and more)
- (Use TanStack Table, when, your table requires complex interactivity (multi-column sort, advanced filter UI, client or server-driven pagination, or large datasets requiring performance optimizations))
- (Tradeoffs, include, increased bundle size and implementation complexity when adopting TanStack vs. smaller simple markup and lower maintenance with shadcn/ui Table)
- (Client vs Server, requires, client-side components (use client) or server-driven RPC for server-side pagination when using TanStack state (unless you only use TanStack for static render mapping))
- (Migration path, is, start with shadcn markup, add TanStack column definitions and a useReactTable instance, then map row rendering to TanStack row models — keep shadcn Table cells for styling)

### {GOAL}
shadcn Table vs Data Table — learn when to use shadcn/ui or TanStack Table in Next.js, optimize sorting, filtering and pagination, and avoid costly…

### {PREREQS}
- Access to Next.js
- Access to shadcn/ui
- Access to TanStack Table (@tanstack/react-table)
- Access to React
- Access to TypeScript

### {STEPS}
1. Assess table complexity
2. Start with shadcn Table for simple lists
3. Identify the inflection point
4. Define columns for TanStack
5. Wire useReactTable in a Data Table
6. Keep page as server component

<!-- llm:goal="shadcn Table vs Data Table — learn when to use shadcn/ui or TanStack Table in Next.js, optimize sorting, filtering and pagination, and avoid costly…" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to shadcn/ui" -->
<!-- llm:prereq="Access to TanStack Table (@tanstack/react-table)" -->
<!-- llm:prereq="Access to React" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:output="Completed outcome: shadcn Table vs Data Table — learn when to use shadcn/ui or TanStack Table in Next.js, optimize sorting, filtering and pagination, and avoid costly…" -->

# shadcn Table vs Data Table: When to Choose TanStack
> shadcn Table vs Data Table — learn when to use shadcn/ui or TanStack Table in Next.js, optimize sorting, filtering and pagination, and avoid costly…
Matija Žiberna · 2026-02-25

I recently rebuilt a page in my Next.js app that lists cost objects — codes like `P-000845` with a name, type, and status. The original version used shadcn's `<Table>` component directly. It worked fine until I needed sorting. Then filtering. Then pagination. At that point I wasn't writing table markup anymore — I was reinventing state management inside a component that was never designed for it.

That's when I switched to a Data Table powered by TanStack Table. But here's the thing: not every table in my app needed that upgrade. Some are still plain `<Table>` components and they're better for it.

This article breaks down both approaches using the same dataset so you can see exactly where one ends and the other begins.

## The Static Table

shadcn/ui ships a `<Table>` component that wraps standard HTML table elements with consistent styling. It's a presentation component — you give it rows, it renders them. Nothing more.

Here's a cost objects table that displays a list fetched on the server:

```tsx
// File: src/components/cost-objects/CostObjectsTable.tsx
"use client";

import Link from "next/link";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";

interface CostObject {
  id: number;
  code: string;
  name: string;
  typeName: string;
  status: string;
}

export function CostObjectsTable({ costObjects }: { costObjects: CostObject[] }) {
  if (costObjects.length === 0) {
    return (
      <div className="p-8 text-center text-muted-foreground border rounded-lg">
        No cost objects found.
      </div>
    );
  }

  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Code</TableHead>
          <TableHead>Name</TableHead>
          <TableHead>Type</TableHead>
          <TableHead>Status</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {costObjects.map((co) => (
          <TableRow key={co.id}>
            <TableCell className="font-mono text-sm">
              <Link href={`/cost-objects/${co.code}`} className="hover:underline">
                {co.code}
              </Link>
            </TableCell>
            <TableCell>{co.name}</TableCell>
            <TableCell className="text-muted-foreground">{co.typeName}</TableCell>
            <TableCell>
              <Badge variant={co.status === "active" ? "default" : "secondary"}>
                {co.status === "active" ? "Active" : "Archived"}
              </Badge>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}
```

This is straightforward. The component receives an array, maps over it, and renders rows. There is no internal state. Sorting order is whatever the server query returned. If you want to filter, you add an input and a `.filter()` call before the `.map()`. If you want pagination, you slice the array and track the current page yourself.

For a table with 10-20 rows that just needs to display data, this is the right tool. It's easy to read, easy to modify, and there's zero abstraction overhead.

## Where the Static Table Breaks Down

The problems start when you need interactivity. Say a user wants to sort by name. You add a `useState` for the sort column and direction, a click handler on the header, and a `.sort()` before the `.map()`. That's manageable. Now they want to sort by code too. And type. You're duplicating the same toggle logic across multiple headers.

Then they want a search input that filters across name and code. Another `useState`, another handler, and now your sort and filter logic need to compose correctly — filter first, then sort, then slice for pagination. Each feature you bolt on adds state and makes the component harder to reason about.

This is the inflection point. Once your table needs two or more of sorting, filtering, or pagination, you're no longer writing a display component — you're building a table engine. And TanStack Table already is one.

## The Data Table

TanStack Table is a headless UI library. It manages table state — sorting, filtering, pagination, selection — but renders nothing. You still use shadcn's `<Table>` for the markup. The difference is that a table instance coordinates everything for you.

The Data Table pattern splits into three files. This separation isn't arbitrary — it follows the server/client boundary in Next.js App Router.

The first file defines your columns. Each column maps to a data field and describes how the header and cell should render:

```tsx
// File: src/components/cost-objects/columns.tsx
"use client";

import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown } from "lucide-react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";

interface CostObject {
  id: number;
  code: string;
  name: string;
  typeName: string;
  status: string;
}

export const columns: ColumnDef<CostObject>[] = [
  {
    accessorKey: "code",
    header: ({ column }) => (
      <Button
        variant="ghost"
        onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
      >
        Code
        <ArrowUpDown className="ml-2 h-4 w-4" />
      </Button>
    ),
    cell: ({ row }) => (
      <Link
        href={`/cost-objects/${row.original.code}`}
        className="font-mono text-sm hover:underline"
      >
        {row.original.code}
      </Link>
    ),
  },
  {
    accessorKey: "name",
    header: ({ column }) => (
      <Button
        variant="ghost"
        onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
      >
        Name
        <ArrowUpDown className="ml-2 h-4 w-4" />
      </Button>
    ),
  },
  {
    accessorKey: "typeName",
    header: "Type",
    cell: ({ row }) => (
      <span className="text-muted-foreground">{row.getValue("typeName")}</span>
    ),
  },
  {
    accessorKey: "status",
    header: "Status",
    cell: ({ row }) => {
      const status = row.getValue("status") as string;
      return (
        <Badge variant={status === "active" ? "default" : "secondary"}>
          {status === "active" ? "Active" : "Archived"}
        </Badge>
      );
    },
  },
];
```

Notice the `code` and `name` columns have sortable headers. That's a one-line addition per column — `column.toggleSorting()` — rather than custom state wiring. The column definition is also where you control cell rendering: links, badges, formatted values. This keeps all column-specific logic in one place instead of scattered across JSX.

The second file is the Data Table component itself. It creates the table instance, wires up the features, and renders using shadcn's `<Table>`:

```tsx
// File: src/components/cost-objects/CostObjectsDataTable.tsx
"use client";

import * as React from "react";
import {
  ColumnDef,
  SortingState,
  ColumnFiltersState,
  flexRender,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  useReactTable,
} from "@tanstack/react-table";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
}

export function CostObjectsDataTable<TData, TValue>({
  columns,
  data,
}: DataTableProps<TData, TValue>) {
  const [sorting, setSorting] = React.useState<SortingState>([]);
  const [columnFilters, setColumnFilters] =
    React.useState<ColumnFiltersState>([]);

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

  return (
    <div className="space-y-4">
      <Input
        placeholder="Filter by name..."
        value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
        onChange={(e) =>
          table.getColumn("name")?.setFilterValue(e.target.value)
        }
        className="max-w-sm"
      />
      <div className="overflow-hidden rounded-md border">
        <Table>
          <TableHeader>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <TableHead key={header.id}>
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext(),
                        )}
                  </TableHead>
                ))}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows?.length ? (
              table.getRowModel().rows.map((row) => (
                <TableRow key={row.id}>
                  {row.getVisibleCells().map((cell) => (
                    <TableCell key={cell.id}>
                      {flexRender(
                        cell.column.columnDef.cell,
                        cell.getContext(),
                      )}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell
                  colSpan={columns.length}
                  className="h-24 text-center"
                >
                  No results.
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>
      <div className="flex items-center justify-end gap-2">
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.previousPage()}
          disabled={!table.getCanPreviousPage()}
        >
          Previous
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.nextPage()}
          disabled={!table.getCanNextPage()}
        >
          Next
        </Button>
      </div>
    </div>
  );
}
```

The key thing happening here is `useReactTable`. You pass it your data, columns, and the model functions you want — `getSortedRowModel`, `getFilteredRowModel`, `getPaginationRowModel` — and it returns a table instance that handles all the state coordination. Sorting, filtering, and pagination all compose automatically. You don't write the logic for how a filter interacts with pagination; the library handles that.

The `flexRender` function is how TanStack connects its headless logic to your actual JSX. It takes a column definition (which can be a string, a component, or a render function) and produces the rendered output. This is what makes the library headless — it computes what to render, `flexRender` bridges that into React elements.

The third file is the server page that fetches data and passes it down:

```tsx
// File: src/app/(frontend)/cost-objects/page.tsx
import { columns } from "@/components/cost-objects/columns";
import { CostObjectsDataTable } from "@/components/cost-objects/CostObjectsDataTable";

export default async function CostObjectsPage() {
  const costObjects = await getCostObjects(); // your data fetching

  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-bold tracking-tight">Cost Objects</h1>
      <CostObjectsDataTable columns={columns} data={costObjects} />
    </div>
  );
}
```

This three-file split matters because columns and the Data Table component must be client components (they use hooks and event handlers), while the page can remain a server component that fetches data without shipping that logic to the browser.

## The Real Difference

Looking at both approaches side by side, the static Table and the Data Table render identical HTML. The same `<table>`, `<thead>`, `<tbody>`, `<tr>`, `<td>` elements. The visual output can be pixel-for-pixel the same.

The difference is where the logic lives. With a static Table, you own every piece of state — sort column, sort direction, filter value, current page. You wire them together manually and hope they compose correctly. With a Data Table, TanStack Table owns that state and exposes it through a consistent API. You declare which features you want by adding model functions, and the library coordinates them.

This is not a small distinction. When you add filtering to a sorted, paginated static table, you need to make sure filtering resets the page index to zero. With TanStack Table, that happens automatically. When you want to make a column sortable, you add one line to the column definition instead of threading state through your component.

## When to Use Each

A static `<Table>` is the right choice when the data is display-only or close to it. A table that shows the five most recent orders on a dashboard. A settings page listing a handful of configuration values. A summary table inside a detail view. These tables have a fixed, small number of rows, and users don't need to search, sort, or page through them. The static Table keeps the code simple and the bundle small.

A Data Table earns its place when users need to interact with the data. The moment you need two or more of these — sorting, column filtering, global search, pagination, row selection — you should reach for TanStack Table. The upfront cost is higher: three files instead of one, a new dependency, and the `useReactTable` API to learn. But that cost is fixed. Each additional feature after the initial setup is a few lines of configuration, not a structural change to your component.

There's a grey zone when you only need one interactive feature, like sorting a single column. You can handle that with a `useState` and a `.sort()` in a static Table and it's fine. But if there's any chance the requirements will grow — and with data tables, they usually do — starting with TanStack Table saves you from a rewrite later.

## Conclusion

The shadcn `<Table>` component and a TanStack-powered Data Table are not competing tools. They serve different complexity levels, and the shadcn `<Table>` is actually used inside the Data Table for rendering. The decision comes down to whether your table is a display component or an interactive one.

If you're rendering a short, static list — use `<Table>` directly. If users need to sort, filter, or paginate — build a Data Table. And if you're on the fence, ask yourself: will this table get more interactive over time? If yes, start with TanStack.

In the next article, I'll walk through building a production Data Table from scratch — including reusable patterns, custom toolbars, and the gotchas I hit along the way.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija

## LLM Response Snippet
```json
{
  "goal": "shadcn Table vs Data Table — learn when to use shadcn/ui or TanStack Table in Next.js, optimize sorting, filtering and pagination, and avoid costly…",
  "responses": [
    {
      "question": "What does the article \"shadcn Table vs Data Table: When to Choose TanStack\" cover?",
      "answer": "shadcn Table vs Data Table — learn when to use shadcn/ui or TanStack Table in Next.js, optimize sorting, filtering and pagination, and avoid costly…"
    }
  ]
}
```