← Back to articles
February 202611 min read

How to Build Better Tables for Analytics Dashboards

Sorting, filtering, pagination, virtualization, and responsive design for dashboard tables. TanStack Table examples for Web3 transaction feeds, holder lists, and token data.

TablesTanStack TableDashboardsReact

Why tables are the backbone of analytics dashboards

Charts show trends. Tables show details. Every analytics dashboard — Web3, finance, or SaaS — relies on tables for transactions, token holders, user lists, and activity logs. A well-built table lets users find, sort, filter, and act on data. A poorly built one frustrates users and breaks on mobile.

Sorting, filtering, and searching

Use TanStack Table (React Table v8) for headless table logic. It handles sorting, filtering, pagination, and column visibility without imposing UI.

// components/tables/TransactionTable.tsx
'use client';

import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  flexRender,
  type ColumnDef,
} from '@tanstack/react-table';
import { useState } from 'react';

type Transaction = {
  hash: string;
  from: string;
  to: string;
  value: string;
  timestamp: number;
  type: 'send' | 'receive' | 'swap';
};

const columns: ColumnDef<Transaction>[] = [
  {
    accessorKey: 'timestamp',
    header: 'Date',
    cell: ({ getValue }) => formatDate(getValue<number>()),
  },
  {
    accessorKey: 'type',
    header: 'Type',
    cell: ({ getValue }) => <TxTypeBadge type={getValue<string>()} />,
  },
  {
    accessorKey: 'value',
    header: 'Amount',
    cell: ({ getValue }) => formatTokenAmount(getValue<string>()),
  },
  {
    accessorKey: 'hash',
    header: 'Tx Hash',
    cell: ({ getValue }) => <ExplorerLink hash={getValue<string>()} />,
  },
];

export function TransactionTable({ data }: { data: Transaction[] }) {
  const [sorting, setSorting] = useState([]);
  const [globalFilter, setGlobalFilter] = useState('');

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

  return (
    <div>
      <input
        value={globalFilter}
        onChange={(e) => setGlobalFilter(e.target.value)}
        placeholder="Search transactions…"
        className="mb-4 w-full rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-sm"
      />
      <table className="w-full text-sm">
        <thead>
          {table.getHeaderGroups().map((hg) => (
            <tr key={hg.id} className="border-b border-white/10">
              {hg.headers.map((header) => (
                <th
                  key={header.id}
                  onClick={header.column.getToggleSortingHandler()}
                  className="cursor-pointer p-3 text-left font-medium text-gray-400"
                >
                  {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 border-white/5 hover:bg-white/[0.02]">
              {row.getVisibleCells().map((cell) => (
                <td key={cell.id} className="p-3">
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
      <div className="mt-4 flex items-center justify-between text-sm text-gray-400">
        <span>Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}</span>
        <div className="flex gap-2">
          <button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Previous</button>
          <button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</button>
        </div>
      </div>
    </div>
  );
}

Responsive table design

On mobile, switch from full tables to card layouts or horizontal scroll with sticky columns.

// Mobile card view for transactions
function TransactionCard({ tx }: { tx: Transaction }) {
  return (
    <div className="rounded-xl border border-white/10 p-4 md:hidden">
      <div className="flex items-center justify-between">
        <TxTypeBadge type={tx.type} />
        <span className="text-sm text-gray-400">{formatDate(tx.timestamp)}</span>
      </div>
      <p className="mt-2 text-lg font-medium">{formatTokenAmount(tx.value)}</p>
      <ExplorerLink hash={tx.hash} className="mt-1 text-xs" />
    </div>
  );
}

Handling large datasets

Server-side pagination for data fetching. Client-side virtualization for rendering. Never load 10,000 rows into memory.

import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualizedTable({ rows }: { rows: Transaction[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const virtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 52,
    overscan: 15,
  });

  return (
    <div ref={parentRef} className="h-[500px] overflow-auto">
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.key}
            style={{
              position: 'absolute',
              top: 0,
              width: '100%',
              height: virtualRow.size,
              transform: `translateY(${virtualRow.start}px)`,
            }}
          >
            <TransactionRow tx={rows[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

Sticky headers and columns

<thead className="sticky top-0 z-10 bg-gray-900">
  <tr>{/* headers */}</tr>
</thead>
<td className="sticky left-0 bg-gray-900">{token.name}</td>

Web3 table examples

**Transaction history:** hash (linked to explorer), type, amount, timestamp, status. Sort by date default. Filter by type.

**Token holders:** rank, address (truncated with copy), balance, % of supply. Paginate at 50.

**Token lists:** logo, name, symbol, price, 24h change, market cap. Sort by any column. Search by name or address.

FAQ

**TanStack Table vs building custom?** Always TanStack Table for production. It handles edge cases in sorting, filtering, and pagination that take weeks to build correctly.

**How many rows before virtualization?** Start virtualizing at 100+ rows. Below that, standard pagination is simpler.

**Should tables be server or client components?** Client components — tables need interactivity. Fetch data in a parent hook or server component and pass as props.

**How do I export table data?** Add a CSV export button that serializes the current filtered/sorted view. Use papaparse or a simple JSON-to-CSV utility.

Want to work together? I build Web3 dashboards and DeFi interfaces.