← Back to blog

Headless Tables for Trading Data with TanStack Table

Building sortable, accessible open-orders and balances tables with TanStack Table's headless model, and why headless beats a styled grid for trading UIs.

Oliver Benns
Oliver Benns

Software engineer · Creator of Hedge UI


Two of the densest surfaces in any exchange UI are the open-orders blotter and the balances panel. Both are tables, both want sorting, both update on every tick, and both have to line up perfectly with a dark, monospace-flavoured design system where a buy is green and a sell is red and a price column is always right-aligned. That last requirement is where most "just drop in a grid" stories quietly fall apart. The moment a designer asks for a progress bar inside a cell, a product icon next to a symbol, or a colour rule that depends on the sign of a number, a styled data grid stops saving you time and starts fighting you.

TanStack Table takes the opposite stance. It ships zero markup and zero styles. You get a row model, a column model, sorting, filtering and pagination state, and a flexRender helper, and you bring your own DOM. For a trading UI that is exactly the trade you want. This post walks through how the Hedge UI demo (@tanstack/react-table 8.21.3 on React 19.2.1 and Tailwind CSS 4.1.17) builds its open-orders and balances tables, and why the headless approach earns its keep when every cell is custom and the data never stops moving.

Headless vs the all-in-one styled grid

The instinct on a deadline is to reach for ag-Grid or MUI's DataGrid. They demo beautifully. The problem is that a trading blotter is almost all exceptions: the symbol cell is a flex row with an icon, the side cell is a coloured label, the progress cell is a shadcn Progress bar, the price cell is a number plus a muted quote-currency suffix. Every one of those is a fight against a grid that has opinions about how a cell should look.

Here is the comparison as it actually played out when picking a table library for the demo.

ConcernHeadless (TanStack Table)All-in-one grid (ag-Grid, MUI DataGrid)
Markup controlYou render every <th> and <td> yourselfGrid owns the DOM; you theme around it
Bundle size~14kb core, tree-shakeable, logic only100kb+ with the styling layer pulled in
ThemingYour Tailwind classes, no overridesCSS variable overrides or !important wars
Custom cellsPlain React, any componentCell renderers within the grid's contract
AccessibilityNative <table> markup, your rolesOften div soup with ARIA bolted on
Sorting / paginationProvided as state, you render the UIBuilt in, but styled their way
VirtualisationBring your own (or skip it)Built in

The honest read: a grid wins when you want a spreadsheet and you want it now. Headless wins when the table is a first-class part of a bespoke design system, which a trading UI always is. The bundle and theming points compound: a styled grid that you then have to override is more code than a headless library plus your own thin wrapper. The one column where the grid clearly leads is virtualisation, and we will come back to that honestly at the end.

Defining columns with a typed row model

Everything starts with the row type. TanStack Table is generic over your data shape, so the column definitions are only as good as the type you feed createColumnHelper. The open-orders row in src/features/open-orders/open-orders.tsx looks like this:

export type Side = "buy" | "sell"; export type OrderType = "limit" | "market"; type Asset = { code: string; precision: number; }; export type OpenOrder = { productId: string; symbol: string; quote: Asset; base: Asset; side: Side; type: OrderType; currentPrice: number; limitPrice?: number; quantity: number; progressPercent: number; submittedAt: string; };

createColumnHelper<OpenOrder>() then gives you accessor calls that are fully aware of that shape. Reference a key that does not exist and the build fails; the cell callback's props.getValue() is typed to the accessed field, not any. This is the payoff of pairing the headless model with strict types, and it is the same discipline covered in TypeScript patterns for financial data in React.

import { createColumnHelper } from "@tanstack/react-table"; const columnHelper = createColumnHelper<OpenOrder>(); const columns = [ columnHelper.accessor("symbol", { header: (h) => <HeaderCell header={h}>Product</HeaderCell>, cell: (props) => { const value = props.getValue(); return ( <div className="flex items-center gap-2"> <ProductIcon baseCode={props.row.original.base.code} /> <div className="flex items-baseline"> <span>{value.split("/")[0]}</span> <span className="text-muted-foreground">/{value.split("/")[1]}</span> </div> </div> ); }, }), // ...side, type, currentPrice, limitPrice, quantity, progress, submittedAt ];

Note props.row.original - that is the full typed row, so a cell can reach sibling fields (here base.code) without re-querying. The balances panel uses the same helper against a Balance type with nested asset and quote objects, and accesses them with dotted keys like columnHelper.accessor("asset.name", ...).

Wiring up the table once

Because the library is headless, you write a single DataTable wrapper once and reuse it for both panels. The demo's src/components/data-table.tsx is the only place that touches useReactTable:

import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef, type SortingState, } from "@tanstack/react-table"; export function DataTable<TData, TValue>({ columns, data, onRowClick, sorting, onSort, }: DataTableProps<TData, TValue>) { const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), onSortingChange: (updaterOrValue) => { if (!onSort) return; const next = typeof updaterOrValue === "function" && sorting ? updaterOrValue(sorting) : (updaterOrValue as SortingState); onSort(next); }, state: { sorting }, enableSortingRemoval: true, }); return ( <Table containerClassName="overflow-visible"> <TableHeader className="sticky top-0 bg-background z-1"> {table.getHeaderGroups().map((group) => ( <TableRow key={group.id} className="text-xs"> {group.headers.map((header) => ( <TableHead key={header.id} scope="col"> {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} </TableHead> ))} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows.map((row) => ( <TableRow key={row.id} onClick={() => onRowClick?.(row.original)}> {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}> {flexRender(cell.column.columnDef.cell, cell.getContext())} </TableCell> ))} </TableRow> ))} </TableBody> </Table> ); }

Only the row models you import are bundled. We pull in getCoreRowModel and getSortedRowModel and nothing else, so there is no filtering, grouping or pagination code shipped to the client. That is the headless dividend in practice: you pay for what you render.

Sorting open orders and balances

Sorting is the feature that justifies a table library on its own, because doing it by hand against live data is where subtle bugs live. TanStack hands you getToggleSortingHandler() and getIsSorted(); you decide what they look like. The shared HeaderCell (src/components/header-cell.tsx) renders a real <button> and swaps a lucide-react 0.525.0 chevron based on direction:

import { ChevronDown, ChevronUp } from "lucide-react"; export const HeaderCell = ({ children, header, align = "left" }) => { const isSorted = header.column.getIsSorted(); const canSort = header.column.getCanSort(); return ( <div className={cn("flex items-center", align === "right" && "justify-end")}> <button className={cn("flex gap-1 items-center py-2", canSort && "cursor-pointer")} onClick={header.column.getToggleSortingHandler()} > {children} {isSorted === "asc" ? <ChevronDown className="w-3 h-3" /> : null} {isSorted === "desc" ? <ChevronUp className="w-3 h-3" /> : null} </button> </div> ); };

A couple of deliberate choices here. enableSortingRemoval: true means a third click on a header clears the sort instead of cycling back to ascending, which is what traders expect from a blotter. Sorting state is lifted out of the table and persisted per panel through usePanelState, so when you sort open orders by submitted time and reload the workspace, the sort survives. Not every column is sortable: price and quantity columns set enableSorting: false because sorting a value that re-renders on every tick is jittery and rarely what you want, whereas sorting by side, type, or submitted time is stable and useful.

The balances panel sorts on derived numbers - portfolio allocation, P&L, and 24h change - and TanStack sorts those just as happily as raw fields because, to the row model, a computed value is just a number on the row.

Rendering cells: prices, quantities, and colour

This is where bringing your own markup pays off. A price is never just a number in a trading UI; it is a number formatted to the instrument's precision with a muted currency suffix. The demo's formatCurrency in src/lib/utils.ts is a thin Intl.NumberFormat wrapper:

export const formatCurrency = (value: number, precision: number) => { return new Intl.NumberFormat("en-US", { minimumFractionDigits: precision, maximumFractionDigits: precision, }).format(value); };

Precision is per asset, which matters more than it sounds. The balances panel holds six assets - BTC, ETH, AAVE, SOL, PEPE and USDC - and PEPE's average price is 0.00001162. A single fixed toFixed(2) would render that as 0.00 and silently lie about the position. Pulling precision from product.base.precision and product.quote.precision per row is the only way the meme coin and Bitcoin can share a column. Getting number formatting and decimal precision right is its own rabbit hole, and the column merging helper (cn(), built on clsx 2.1.1 and tailwind-merge 3.4.0) keeps the conditional classes tidy.

Colour is the other custom-cell win. Buy and sell get semantic foreground tokens, and P&L cells flip on the sign of the value:

const sideColors: Record<Side, string> = { buy: "text-bid-foreground", sell: "text-ask-foreground", }; // inside the P&L cell const sentiment = value === 0 ? undefined : value > 0 ? "text-bid-foreground" : "text-ask-foreground"; return ( <div className={cn("flex items-baseline gap-1 justify-end", sentiment)}> <span className="font-medium">{formatCurrency(value, quote.precision)}</span> <span>{baseCurrencyCode}</span> </div> );

Tying colour to bid/ask design tokens rather than literal text-green-500 means a re-theme changes one token and every blotter follows. The balances table is the centre of gravity for this work; the wider story of composing it lives in building a multi-asset portfolio interface in React.

Accessibility comes from the markup

The cheapest accessibility win in this whole stack is that the demo renders a genuine <table> with <thead>, <th scope="col">, <tbody> and <td>. Native table elements carry the implicit ARIA roles - table, columnheader, cell, row - so a screen reader announces "row 4 of 100, Product column, BTC/USDT" without a single hand-written role attribute. This is precisely the thing the div-based grids have to reconstruct with explicit roles, and they get it wrong often enough that it is a real differentiator.

Because the sort control is a real <button> and not a click handler on a <div>, it is keyboard focusable and operable with Enter or Space for free, and it reads as a button to assistive tech. Headless tables make accessibility a markup decision rather than a configuration option, which is the right place for it to live. The full treatment of keyboard and screen-reader support for these surfaces is in accessibility in trading UIs.

Keeping cells cheap under a throttled ticker

A blotter that re-sorts and re-renders on every WebSocket frame will melt a laptop. Open orders in the demo derives mark price from a live Binance ticker, and the panel is built to keep that cheap in two layers.

First, the live ticker map is throttled before it ever reaches the row model. useThrottledValue(tickerStreams, 1000) caps the table's data input to once per second, so prices update visibly but the row model recomputes at most once a second rather than on every inbound message:

const tickerStreams = useTickerStreams(streamNames); const throttledTickerStreams = useThrottledValue(tickerStreams, 1000); const rows = useMemo<OpenOrder[]>(() => { // build 100 orders, attaching currentPrice from the throttled map // buy limit price = initialPrice * 0.75, sell = initialPrice * 1.25 }, [throttledTickerStreams, productsById]);

Second, the rendered table component is wrapped: const MemoizedOpenOrders = memo(OpenOrders). The panel container subscribes to the stream and recomputes rows, but the memoised table only re-renders when its data, sorting or callbacks actually change. The callbacks themselves are stabilised with useCallback, so a throttle tick that produces an identical reference does not cascade into a full table re-render.

That throttle interval is a deliberate latency-versus-cost choice. A blotter is a glanceable summary, not the order book, so one second is fine; the order book itself runs on a tighter budget. The mechanics of taming high-frequency input live in throttling and batching market data in React, and the broader render-cost techniques in performant React trading applications.

What headless costs you: pagination and virtualisation

Headless is not free. The bill arrives as features you would have got for nothing from a grid and now have to build or decline. The two big ones are pagination and virtualisation.

The demo declines both, on purpose. Open orders is a fixed 100 rows generated across seven products, with timestamps spanning from 30 days ago to an hour ago, and balances is six rows. At those counts the right answer is to render every row, skip pagination entirely, and let the panel scroll with a sticky header. Reaching for getPaginationRowModel would add UI and state for a problem that does not exist at this scale.

Virtualisation is the more interesting omission. There is no @tanstack/react-virtual anywhere in these tables. A hundred fully rendered rows is well within what React 19 handles without a windowing layer, and adding one would mean giving up the simple native <table> scroll and managing absolute positioning and row heights yourself. It is worth being precise about what the demo does and does not do: the order book solves its own density problem with plain height-based slicing - it measures available space and renders only the price levels that fit - rather than a general virtualiser. Different surface, different tool, neither uses a library.

The line to draw is roughly this:

Row countApproach
Tens to low hundredsRender everything, native scroll
Hundreds to low thousandsAdd @tanstack/react-virtual, keep the table headless
Tens of thousands, frequent updatesVirtualise and consider a canvas or grid-native renderer

A trade history that grows unbounded, or a full market-wide ticker list, is where virtualisation stops being premature and becomes the difference between 60fps and a stutter. Because the table is headless, dropping a virtualiser into the existing DataTable later is an additive change, not a rewrite - you keep your columns, your cells, and your sorting, and only the row-rendering loop changes. Knowing where that threshold sits is part of the wider latency budgets for trading UIs conversation.

Closing guidance

If you are building tables for a trading UI, start headless. The grid's convenience is real but it is convenience for the wrong problem; your cells are bespoke, your theme is opinionated, and your accessibility wants to ride on native markup rather than reconstructed roles. TanStack Table gives you sorting and a typed row model and gets out of the way of everything else.

Concretely: define one strict row type per table and let createColumnHelper enforce it; write a single DataTable wrapper so both blotters share sorting, header rendering and flexRender; format every number to its instrument's precision so a meme coin and Bitcoin can share a column; tie colour to design tokens, not literal classes; lean on native <table> elements for accessibility; and throttle plus memoise the data path so a live ticker does not re-render the world. Skip pagination and virtualisation until row counts actually demand them, and when they do, add the virtualiser inside the wrapper you already own. That is the whole shape of it, and it scales from a six-row balances panel to a hundred-row blotter without changing approach.

Kickstart Your Trading Application

Hedge UI is a React starter kit with production-ready trading components, real-time data handling, and customisable layouts — so you can ship faster.

Get Hedge UI