Skip to main content

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.

SdvTable.tsx
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.

Kansas City ChiefsKCWest152385326+0.06310th
Buffalo BillsBUFEast134525368+0.1904th
Los Angeles ChargersLACWest116402301+0.1019th
Denver BroncosDENWest107425311+0.1088th
Miami DolphinsMIAEast89345364−0.01921st
New York JetsNYJEast512338404−0.04524th
Las Vegas RaidersLVWest413309434−0.14626th
New England PatriotsNEEast41328941729th
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.

TanstackStandings.svelte
<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>
CellPart.svelte
<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.

RankTeamWLWin %
1stLA Clippers513162.2%
2ndPhoenix Suns493359.8%
3rdLos Angeles Lakers473557.3%
4thSacramento Kings463656.1%
5thGolden State Warriors463656.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.