TanStack Table
TanStack Table is a headless table library: it sorts, filters, groups and pages your rows,
and you write the <table> markup and its CSS yourself, in React, Svelte, Vue, Solid, Angular or plain JavaScript.
@sportsdataverse/sdvtables/tanstack-table turns an sdvtables spec into TanStack Table v9 options, so the same spec that
renders a static HTML table also drives a TanStack table, with its labels, number formats, sort, logos and headshots.
The subpath never imports TanStack Table at runtime: it uses @tanstack/table-core's types only, and it draws nothing
itself, so it loads on a server and in any framework. @tanstack/table-core is an optional peer, 9.3.1 or later; these
examples run 9.3.1, with @tanstack/react-table 9.3.1 in React and @tanstack/svelte-table 9.3.1 in Svelte 5. Both
adapters install table-core themselves; the package's type declarations name table-core's types, so TypeScript needs it
resolvable from your project (with pnpm, add it as a dependency of your own).
A spec as table options
tanstackTable(spec, rows, { features, render }) returns options and columnStyle. Pass options to React's
useTable or Svelte's createTable, alone or spread with your own options (initialState, pagination and the rest).
features is your table's own tableFeatures({ ... }), passed through unchanged: TanStack Table v9 only sorts when you
register rowSortingFeature and sortedRowModel: createSortedRowModel(), and the columns' types follow the features
you pass. tanstackTable is synchronous, so first await prepare(spec) from @sportsdataverse/sdvtables/html, which
loads the leagues the logo columns need. The logos are worked out once, from the rows you pass, so build the options
again when the rows change, and keep them stable between renders: at module scope, or in a memo.
Each spec column becomes one TanStack column, its id the spec's key and its header the label the HTML table gives it
(with a scaleNote's suffix). num, int, pct and rank cells are the HTML table's own text: grouped thousands,
fixed decimals, a forced sign, U+2212 minus signs, ordinals, and a scaleNote's divisor and decimals. A blank or
unparseable value in a number column is missing: an empty cell, never "NaN" or 0. columnStyle gives each column's
alignment and the spec's width, for its header and cells.
Sorting is sdvtables' own, through each column's sortFn: a column's compare if it has one, else numbers for the
numeric kinds and text otherwise, with missing values last whether you sort ascending or descending (sortUndefined: "last"), as in the interactive HTML table. A spec column with sortable: false cannot be sorted. TanStack sorts a
number column descending on its first click, then ascending.
With the spec's rowKey, getRowId gives each row that value, so TanStack keeps a row's id when the table re-sorts or
the rows change; tanstackTable throws when two rows share a rowKey value or a row's value is blank, since TanStack
would treat the two rows as one. Without a rowKey, TanStack numbers the rows itself.
Logos, headshots and ranks
TanStack Table draws nothing, so a cell that is more than text comes to your render function as plain data, a
CellPart: a logo, wordmark or headshot image (its src, alt, height and the team or player id, and the
team's name after it with includeName), or a rank's number and its superscript suffix. render returns whatever your
framework draws: a React element, or, in Svelte, renderComponent(CellPart, { part }). Without render, those cells are
their text. The team can be an abbreviation, a name or a numeric id, as a number or as a string. A team with no image in
the archive, or one that is not found, keeps its text and warns once, as the HTML table does; so does a player with no
headshot. A headshot's alt is empty: it is decorative, as the player's name belongs in a column of its own.
In React
This component draws a TanStack table from tanstackTable's options: the headers are buttons that sort, each th says
how its column is sorted (aria-sort), and renderPart draws the images and superscript ranks. The table wears the
spec's sdvtables theme: the theme's stylesheet, scoped to its class, styles the th.sdvt-label and td.sdvt-cell it
writes, as it does the HTML table's. The examples use this file as it is, and the tests render it.
import type { TableSpec } from "@sportsdataverse/sdvtables";
import { styleSheet, themeKey } from "@sportsdataverse/sdvtables/html";
import type { CellPart, SdvTanstackTable } from "@sportsdataverse/sdvtables/tanstack-table";
import {
type RowData,
type SortingState,
createSortedRowModel,
rowSortingFeature,
tableFeatures,
useTable,
} from "@tanstack/react-table";
import type { ReactElement, ReactNode } from "react";
/** The table's TanStack Table v9 features: sorting, client side. Module scope, so it stays the same object. */
export const features = tableFeatures({ rowSortingFeature, sortedRowModel: createSortedRowModel() });
/** sdvtables' image and superscript-rank cells as React elements: tanstackTable's `render`. */
export function renderPart(p: CellPart): ReactNode {
if (p.type === "rank")
return (
<>
{p.num}
<sup style={{ fontSize: p.size }}>{p.suffix}</sup>
</>
);
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4em" }}>
<img src={p.src} alt={p.alt} height={p.height} data-sdv-id={p.id} data-sdv-kind={p.kind} />
{p.text}
</span>
);
}
const ARROW = { asc: " ▲", desc: " ▼" } as const;
const ARIA = { asc: "ascending", desc: "descending" } as const;
/**
* A sortable table from `tanstackTable(spec, rows, { features, render: renderPart })`, in the spec's sdvtables theme:
* its stylesheet, scoped to the theme's class, styles the `th.sdvt-label` and `td.sdvt-cell` here as it does the HTML
* table's. Click a header to sort; missing values stay last both ways.
*/
export function SdvTable<TData extends RowData>({
spec,
sdv,
sorting = [],
}: {
spec: TableSpec<TData>;
sdv: SdvTanstackTable<typeof features, TData>;
/** The sort to start with: `[{ id: "wins", desc: true }]`. */
sorting?: SortingState;
}): ReactElement {
const table = useTable({ ...sdv.options, initialState: { sorting } });
return (
<div className={themeKey(spec.theme)}>
<style>{styleSheet(spec)}</style>
<table>
<thead>
{table.getHeaderGroups().map((g) => (
<tr key={g.id}>
{g.headers.map((h) => {
const sorted = h.column.getIsSorted();
return (
<th
key={h.id}
className="sdvt-label"
style={sdv.columnStyle[h.column.id]}
aria-sort={sorted ? ARIA[sorted] : "none"}
>
{h.column.getCanSort() ? (
<button
type="button"
onClick={h.column.getToggleSortingHandler()}
style={{ all: "inherit", cursor: "pointer" }}
>
<table.FlexRender header={h} />
{sorted ? ARROW[sorted] : ""}
</button>
) : (
<table.FlexRender header={h} />
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((r) => (
<tr key={r.id} data-row-id={r.id}>
{r.getAllCells().map((c) => (
<td
key={c.id}
className="sdvt-cell"
data-col={c.column.id}
style={sdv.columnStyle[c.column.id]}
>
<table.FlexRender cell={c} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
The sample AFC standings, sorted by wins to start. Click a header to sort by it; New England's blank net EPA stays last both ways.
| West | 15 | 2 | 385 | 326 | +0.063 | 10th | |
| East | 13 | 4 | 525 | 368 | +0.190 | 4th | |
| West | 11 | 6 | 402 | 301 | +0.101 | 9th | |
| West | 10 | 7 | 425 | 311 | +0.108 | 8th | |
| East | 8 | 9 | 345 | 364 | −0.019 | 21st | |
| East | 5 | 12 | 338 | 404 | −0.045 | 24th | |
| West | 4 | 13 | 309 | 434 | −0.146 | 26th | |
| East | 4 | 13 | 289 | 417 | 29th |
import { STANDINGS, type Standing } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { fontsLinkFor, prepare } from "@sportsdataverse/sdvtables/html";
import { tanstackTable } from "@sportsdataverse/sdvtables/tanstack-table";
import { useEffect } from "react";
import { SdvTable, features, renderPart } from "../../../snippets/tanstack-table/SdvTable.js";
// the eight AFC East and West teams of the sample standings; New England's net EPA is blank, so it sorts last both ways
const spec = defineTable<Standing>()
.columns((c) => [
c.logo("team", { league: "nfl", label: "Team", includeName: true, height: 28 }),
c.text("division"),
c.int("wins", { label: "W" }),
c.int("losses", { label: "L" }),
c.int("pf", { label: "PF" }),
c.int("pa", { label: "PA" }),
c.num("net_epa", { label: "Net EPA/play", digits: 3, forceSign: true }),
c.rank("srs_rank", { label: "SRS rank" }),
])
.theme("sdv")
.rowKey("team")
.build();
await prepare(spec);
// TanStack Table's options for useTable, built once (module scope): the columns, the rows and their ids by team
const sdv = tanstackTable(spec, STANDINGS, { features, render: renderPart });
function Standings() {
// the theme's fonts: the page <head> carries the link fontsLinkFor returns
useEffect(() => document.head.insertAdjacentHTML("beforeend", fontsLinkFor(spec)), []);
// sorted by wins to start; click a header to sort by it
return <SdvTable spec={spec} sdv={sdv} sorting={[{ id: "wins", desc: true }]} />;
}
<Standings />;
In Svelte
The same table in Svelte 5, through @tanstack/svelte-table's createTable and FlexRender, with a small component
for the images and ranks.
<script lang="ts">
// A sortable TanStack Table v9 in Svelte 5, built from an sdvtables spec by tanstackTable: the spec's labels, number
// formats and sort (missing values last both ways), with logos and superscript ranks drawn by CellPart.svelte.
// The spec's sdvtables theme styles it: its stylesheet, scoped to the theme's class, styles th.sdvt-label and
// td.sdvt-cell. Call `await prepare(spec)` before mounting it.
import type { TableSpec } from "@sportsdataverse/sdvtables";
import { styleSheet, themeKey } from "@sportsdataverse/sdvtables/html";
import { tanstackTable } from "@sportsdataverse/sdvtables/tanstack-table";
import {
FlexRender,
type SortingState,
createSortedRowModel,
createTable,
renderComponent,
rowSortingFeature,
tableFeatures,
} from "@tanstack/svelte-table";
import { untrack } from "svelte";
import CellPart from "./CellPart.svelte";
type Row = Record<string, unknown>;
interface Props {
spec: TableSpec<Row>;
rows: Row[];
/** The sort to start with: `[{ id: "wins", desc: true }]`. */
sorting?: SortingState;
}
const props: Props = $props();
const features = tableFeatures({ rowSortingFeature, sortedRowModel: createSortedRowModel() });
// built once, from the props as first passed (untrack says so): the columns, the rows and their ids; CellPart
// draws a logo or a superscript rank. Mount the component again for other rows.
const sdv = untrack(() =>
tanstackTable(props.spec, props.rows, { features, render: (part) => renderComponent(CellPart, { part }) }),
);
const table = createTable({ ...sdv.options, initialState: { sorting: untrack(() => props.sorting) ?? [] } });
const ARROW = { asc: " ▲", desc: " ▼" } as const;
const ARIA = { asc: "ascending", desc: "descending" } as const;
</script>
<div class={themeKey(props.spec.theme)}>
{@html `<style>${styleSheet(props.spec)}</style>`}
<table>
<thead>
{#each table.getHeaderGroups() as group (group.id)}
<tr>
{#each group.headers as header (header.id)}
{@const sorted = header.column.getIsSorted()}
{@const style = sdv.columnStyle[header.column.id]}
<th
class="sdvt-label"
style:text-align={style?.textAlign}
style:width={style?.width}
aria-sort={sorted ? ARIA[sorted] : "none"}
>
{#if header.column.getCanSort()}
<button
type="button"
onclick={header.column.getToggleSortingHandler()}
style="all:inherit;cursor:pointer"><FlexRender {header} />{sorted ? ARROW[sorted] : ""}</button
>
{:else}
<FlexRender {header} />
{/if}
</th>
{/each}
</tr>
{/each}
</thead>
<tbody>
{#each table.getRowModel().rows as row (row.id)}
<tr data-row-id={row.id}>
{#each row.getAllCells() as cell (cell.id)}
{@const style = sdv.columnStyle[cell.column.id]}
<td class="sdvt-cell" data-col={cell.column.id} style:text-align={style?.textAlign} style:width={style?.width}
><FlexRender {cell} /></td
>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<script lang="ts">
// One sdvtables cell that is more than text, as tanstackTable hands it over: a logo, wordmark or headshot image (and
// the team's name after it, with includeName), or a rank with its superscript suffix.
import type { CellPart } from "@sportsdataverse/sdvtables/tanstack-table";
const { part }: { part: CellPart } = $props();
</script>
{#if part.type === "rank"}{part.num}<sup style:font-size={part.size}>{part.suffix}</sup>{:else}<span
style="display:inline-flex;align-items:center;gap:0.4em"
><img
src={part.src}
alt={part.alt}
height={part.height}
data-sdv-id={part.id}
data-sdv-kind={part.kind}
/>{part.text ?? ""}</span
>{/if}
This one is the 2023-24 NBA Pacific Division on the dark midnight theme, keyed by stats.nba.com's numeric team id,
which resolves to each team's logo. It starts sorted by win percentage.
| Rank | Team | W | L | Win % | |
|---|---|---|---|---|---|
| 1st | ![]() | LA Clippers | 51 | 31 | 62.2% |
| 2nd | ![]() | Phoenix Suns | 49 | 33 | 59.8% |
| 3rd | ![]() | Los Angeles Lakers | 47 | 35 | 57.3% |
| 4th | ![]() | Sacramento Kings | 46 | 36 | 56.1% |
| 5th | ![]() | Golden State Warriors | 46 | 36 | 56.1% |
import { NBA_STANDINGS } from "@sportsdataverse/examples/data";
import { defineTable } from "@sportsdataverse/sdvtables";
import { prepare, renderHTML } from "@sportsdataverse/sdvtables/html";
// the five Pacific rows, keyed by stats.nba.com's numeric TeamID: the logos resolve from the number
const rows = NBA_STANDINGS.map((s) => ({
team_id: s.team_id,
name: `${s.city} ${s.team}`,
wins: s.wins,
losses: s.losses,
win_pct: s.wins / (s.wins + s.losses),
division_rank: s.division_rank,
}));
type Row = (typeof rows)[number];
const spec = defineTable<Row>()
.columns((c) => [
c.rank("division_rank", { label: "Rank" }),
c.logo("team_id", { league: "nba", label: "", height: 32, sortable: false }),
c.text("name", { label: "Team" }),
c.int("wins", { label: "W" }),
c.int("losses", { label: "L" }),
c.pct("win_pct", { label: "Win %", digits: 1, scale: true }),
])
.theme("midnight")
.rowKey("team_id")
.build();
await prepare(spec);
// In the browser: examples/snippets/svelte/TanstackStandings.svelte, mounted with these props (Svelte's mount()). It
// builds the table with tanstackTable and @tanstack/svelte-table's createTable; click a header to sort.
export const browser = {
lib: "svelte",
component: "TanstackStandings",
props: { spec, rows, sorting: [{ id: "win_pct", desc: true }] },
label:
"The 2023-24 Pacific Division as a sortable TanStack Table on a dark background, each team's logo by its stats.nba.com id",
} as const;
// Svelte draws only in a browser here; without JavaScript the page shows the same table as sdvtables' HTML
renderHTML(spec, rows);
The themes name their fonts but do not load them: put the <link> that fontsLinkFor(spec) returns in the page's
<head>, as the React example does.
From the v8 recipe
Before v9, the sdvtables README had a recipe for @tanstack/react-table 8: a toTanStackColumns function that read
each cell's markup back from toElement(). TanStack Table v9 changed its API, so the recipe does not run on v9, and
tanstackTable replaces it. In v9, useTable replaces useReactTable; features and row models are registered in
tableFeatures (rowSortingFeature and createSortedRowModel() where v8 took getSortedRowModel(), and the core row
model needs no option); a column's sortingFn is now sortFn; and ColumnDef takes your features' type as its first
parameter. The new helper also needs no DOM, so the columns can be built on a server, and its cells are the spec's own
text and images rather than copied markup.
What is not mapped
The cell kinds that draw in HTML or SVG (pills, bars, sparklines, gauges, icons, team colour cells and the rest) show
their plain value, with one warning per kind; render those tables with renderHTML or <SdvTable> instead, or with a
data grid (MUI X Data Grid or AG Grid) for team-coloured rows. Decorations
(titles, source notes, spotlights, legends) are the page's to add around the table; a scaleNote scales its columns and
labels, but its note is yours to place. Filtering, pagination and the other TanStack features work as usual: register
them in your tableFeatures.
Testing and server rendering
tanstackTable returns plain values and functions and touches no DOM, so building the options on a server is safe.
Each image renderPart draws carries data-sdv-id (the team or player id) and data-sdv-kind
(logo, wordmark or headshot), so a test can read back which images a table drew and in which rows.
In the gallery: TanStack Table.




