TanStack Table is a headless table library. It manages your table's state and logic (sorting, filtering, pagination, selection, and more) while you keep 100% control over the markup and styles. This page gets you from install to a rendering Ember table, then shows how to layer on your first feature.
npm install @tanstack/ember-table@tanstack/ember-table is a v2 addon that requires Ember 5.8+ with Embroider (or ember-auto-import v2). It works with .gts/.gjs template tag components and Glint.
The adapter is built around two ideas:
Important
TanStack Table v9 uses prototype-based methods that require this binding. Ember templates extract function references without binding them, so wrap table and column method calls in small helper functions (or getters) instead of calling them directly in templates. You will see this pattern in the sorting example below.
Define your table in a Glimmer component with the template tag format:
import Component from '@glimmer/component'
import { tracked } from '@glimmer/tracking'
import {
useTable,
FlexRenderCell,
FlexRenderHeader,
tableFeatures,
createColumnHelper,
type Row,
type Cell,
} from '@tanstack/ember-table'
// 1. Define the shape of your data
interface Person {
firstName: string
lastName: string
age: number
}
const data: Array<Person> = [
{ firstName: 'Tanner', lastName: 'Linsley', age: 33 },
{ firstName: 'Kevin', lastName: 'Vandy', age: 27 },
]
// 2. Opt in to the features you need (none yet)
const features = tableFeatures({})
// 3. Define your columns with the column helper
const columnHelper = createColumnHelper<typeof features, Person>()
const columns = columnHelper.columns([
columnHelper.accessor('firstName', {
header: 'First Name',
cell: (info) => info.getValue(),
}),
columnHelper.accessor('lastName', {
header: 'Last Name',
cell: (info) => info.getValue(),
}),
columnHelper.accessor('age', {
header: 'Age',
cell: (info) => info.renderValue(),
}),
])
// 4. Template helpers: ember templates extract methods unbound, so wrap
// table/row method calls that need `this`
const getAllCells = (
row: Row<typeof features, Person>,
): Array<Cell<typeof features, Person>> => row.getAllCells()
// 5. Create the table and render it
export default class PersonTable extends Component {
@tracked data = data
table = useTable(() => ({
features,
columns,
data: this.data,
}))
get headerGroups() {
return this.table.getHeaderGroups()
}
get rows() {
return this.table.getRowModel().rows
}
<template>
<table>
<thead>
{{#each this.headerGroups as |headerGroup|}}
<tr>
{{#each headerGroup.headers as |header|}}
<th colspan={{header.colSpan}}>
{{#unless header.isPlaceholder}}
<FlexRenderHeader @header={{header}} />
{{/unless}}
</th>
{{/each}}
</tr>
{{/each}}
</thead>
<tbody>
{{#each this.rows as |row|}}
<tr>
{{#each (getAllCells row) as |cell|}}
<td><FlexRenderCell @cell={{cell}} /></td>
{{/each}}
</tr>
{{/each}}
</tbody>
</table>
</template>
}Because data is @tracked and the options thunk reads this.data, assigning a new array re-runs the row model and re-renders the table.
Features are opt-in. Import the feature and its row model, add them to tableFeatures, and wire up a click handler:
import { on } from '@ember/modifier'
import {
useTable,
FlexRenderCell,
FlexRenderHeader,
tableFeatures,
createColumnHelper,
rowSortingFeature,
createSortedRowModel,
sortFns,
type Column,
} from '@tanstack/ember-table'
const features = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
})
// Helpers keep `this` bound correctly when called from templates
const getCanSort = (column: Column<typeof features, Person>): boolean =>
column.getCanSort()
const toggleSort = (column: Column<typeof features, Person>) => {
return (event: Event) => {
column.getToggleSortingHandler()?.(event)
}
}Then attach the handler in your header markup:
<th colspan={{header.colSpan}}>
{{#unless header.isPlaceholder}}
<div
{{on 'click' (toggleSort header.column)}}
style='cursor: {{if (getCanSort header.column) "pointer" "default"}}'
>
<FlexRenderHeader @header={{header}} />
</div>
{{/unless}}
</th>Return flexRenderComponent from a column's cell (or header/footer) definition to render a Glimmer component with custom arguments:
import Component from '@glimmer/component'
import {
flexRenderComponent,
type CellRenderableSignature,
} from '@tanstack/ember-table'
// Use `CellRenderableSignature` for a cell component: its `ctx` is a
// `CellContext`, so `ctx.getValue()` is available directly with no cast. Use
// `FlexRenderableSignature` only for a component rendered in both cell and
// header slots; its `ctx` is a cell-or-header union.
class StatusBadge extends Component<
CellRenderableSignature<typeof features, Person, string, { color: string }>
> {
get value(): string {
return this.args.ctx.getValue()
}
<template>
<span class='badge badge-{{@options.color}}'>{{this.value}}</span>
</template>
}
const columns = columnHelper.columns([
columnHelper.accessor('status', {
header: 'Status',
cell: () => flexRenderComponent(StatusBadge, { color: 'blue' }),
}),
])When multiple tables in your app share the same features, row models, and default options, define them once with createTableHook instead of repeating them at every call site:
const features = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
})
const { createAppTable, createAppColumnHelper } = createTableHook({ features })Then call createAppTable(() => ({ columns, data: this.data })) from your component instead of useTable (it takes the same options thunk, minus features), and define columns with createAppColumnHelper. See the Composable Tables Guide and the Basic createAppTable example for the full pattern.