Want to skip to the implementation? Check out these examples:
If you boil TanStack Table down to one sentence: TanStack Table is a large state-management coordinator for table states.
Understanding this guide is fundamental to understanding how TanStack Table works and how to interact with it for the best results.
You usually do NOT need to manage table state yourself. If you pass nothing to initialState, atoms, state, or any of the on[State]Change table options, TanStack Table will manage its own state internally.
There will be situations where you need to customize how you interact with the internal table state, or even hoist it up to your own scopes. TanStack Table lets you read, subscribe to, or own the state slices that matter to your app. This guide explains how table state works in Lit, how to read it, and when to use external atoms or external state.
TanStack Table v9 overhauled state management around TanStack Store. TanStack Store uses the alien-signals implementation and supports performant derived state. For Lit, the table adapter wires TanStack Store atoms into a TableController.
A table instance has a few state surfaces:
The Lit adapter provides litReactivity() to the table's coreReativityFeature. Readonly and writable atoms are TanStack Store atoms. TableController subscribes to table.store and table.optionsStore; atom or options changes flowing through those stores call host.requestUpdate().
State slices are only created for the features that are registered in _features. This keeps TanStack Table tree-shakeable and gives TypeScript more accurate state inference.
const _features = tableFeatures({
rowPaginationFeature,
rowSortingFeature,
})
const table = this.tableController.table({
_features,
_rowModels: {
paginatedRowModel: createPaginatedRowModel(),
sortedRowModel: createSortedRowModel(sortFns),
},
columns,
data: this._data,
})
table.atoms.pagination.get()
table.atoms.sorting.get()
// table.atoms.rowSelection // TypeScript error unless rowSelectionFeature is registeredconst _features = tableFeatures({
rowPaginationFeature,
rowSortingFeature,
})
const table = this.tableController.table({
_features,
_rowModels: {
paginatedRowModel: createPaginatedRowModel(),
sortedRowModel: createSortedRowModel(sortFns),
},
columns,
data: this._data,
})
table.atoms.pagination.get()
table.atoms.sorting.get()
// table.atoms.rowSelection // TypeScript error unless rowSelectionFeature is registeredIf _features does not include a feature, its state should not be available in table.atoms, table.store.state, table.state, initialState, state, or atoms.
There are two different questions when reading table state:
Use a direct atom or store read for the current value. Use table.state or table.Subscribe in render output when the host should reflect selected table state.
The simplest and most performant way to read a current state value is to read the matching atom:
const pagination = table.atoms.pagination.get()
const sorting = table.atoms.sorting.get()const pagination = table.atoms.pagination.get()
const sorting = table.atoms.sorting.get()You can also read the current flat store snapshot:
const tableState = table.store.state
const pagination = table.store.state.paginationconst tableState = table.store.state
const pagination = table.store.state.paginationThese reads are current-value reads. The TableController handles host invalidation through its subscriptions to the table store and options store. If the UI needs to stay reactive to table state changes, use table.state, table.Subscribe, or a TanStack Store subscription.
The second argument to tableController.table(...) is a TanStack Store selector. The selected value is exposed as table.state. The default selector selects all registered table state.
const table = this.tableController.table(
{
_features,
_rowModels: {
paginatedRowModel: createPaginatedRowModel(),
},
columns,
data: this._data,
},
(state) => ({
pagination: state.pagination,
}),
)
table.state.paginationconst table = this.tableController.table(
{
_features,
_rowModels: {
paginatedRowModel: createPaginatedRowModel(),
},
columns,
data: this._data,
},
(state) => ({
pagination: state.pagination,
}),
)
table.state.paginationUse table.Subscribe in templates to select a slice of table state while rendering.
${table.Subscribe({
selector: (state) => ({
pagination: state.pagination,
}),
children: ({ pagination }) => html`
<span>Page ${pagination.pageIndex + 1}</span>
`,
})}${table.Subscribe({
selector: (state) => ({
pagination: state.pagination,
}),
children: ({ pagination }) => html`
<span>Page ${pagination.pageIndex + 1}</span>
`,
})}table.Subscribe can also accept a source, but in the current Lit adapter host invalidation is wired through the full table.store subscription. Treat source mode as a render-time selection convenience, not a guarantee of source-only host invalidation.
You should almost never need to set table state directly. TanStack Table features expose dedicated APIs for interacting with their state, and those APIs are the safest way to make changes.
table.nextPage()
table.previousPage()
table.setPageIndex(0)
table.setPageSize(25)table.nextPage()
table.previousPage()
table.setPageIndex(0)
table.setPageSize(25)Use APIs like table.setSorting(...), table.setColumnFilters(...), column.toggleVisibility(), or row.toggleSelected() instead of manually editing the underlying state object.
If you only care about setting starting values, use initialState. If you want to reset a state slice back to its initial value, use that feature's reset API.
If you really do need to write a state slice directly, the low-level write surface for internally owned state is the matching base atom:
table.baseAtoms.pagination.set((old) => ({
...old,
pageIndex: 0,
}))table.baseAtoms.pagination.set((old) => ({
...old,
pageIndex: 0,
}))Direct base atom writes should be rare. If a slice is owned by an external atom passed through atoms, write to that external atom instead; table.atoms.pagination will read from the external atom, not the internal base atom.
If you only need to customize the starting value for some table state, use initialState. You still do not need to manage that state yourself.
initialState only applies to registered state slices. It is used to create the table's initial state and is also used by reset APIs such as table.resetSorting() or table.resetPagination(). Changing the initialState object later does not reset table state.
const table = this.tableController.table({
_features,
_rowModels: {
sortedRowModel: createSortedRowModel(sortFns),
paginatedRowModel: createPaginatedRowModel(),
},
columns,
data: this._data,
initialState: {
sorting: [
{
id: 'age',
desc: true,
},
],
pagination: {
pageIndex: 0,
pageSize: 25,
},
},
})const table = this.tableController.table({
_features,
_rowModels: {
sortedRowModel: createSortedRowModel(sortFns),
paginatedRowModel: createPaginatedRowModel(),
},
columns,
data: this._data,
initialState: {
sorting: [
{
id: 'age',
desc: true,
},
],
pagination: {
pageIndex: 0,
pageSize: 25,
},
},
})Note: Do not provide the same state slice in multiple ownership places unless you intentionally want one to win. For a slice like pagination, prefer exactly one of initialState.pagination, atoms.pagination, or state.pagination as the source of truth. External atoms take precedence over external state; external state syncs into the table's internal base atom.
Feature reset APIs reset to table.initialState by default. Many reset APIs also accept true to reset to that feature's blank/default state instead:
table.resetSorting()
table.resetPagination()
table.resetPagination(true)table.resetSorting()
table.resetPagination()
table.resetPagination(true)Slice reset APIs like resetPagination() update through that feature's state updater and can update an externally owned atom. The core table.reset() API resets the internal base atoms, so do not use it as the primary way to reset state that is owned by external atoms.
If you need easy access to table state in other parts of your application, you can control individual state slices. In v9, external atoms are the recommended way to do this when you want atomic ownership.
Use external atoms when the app should own one or more table state slices. Create stable writable atoms with createAtom from @tanstack/store, pass them to atoms, and read them where needed.
import { createAtom } from '@tanstack/store'
import {
TableController,
rowPaginationFeature,
tableFeatures,
type PaginationState,
} from '@tanstack/lit-table'
const _features = tableFeatures({
rowPaginationFeature,
})
const paginationAtom = createAtom<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const table = this.tableController.table({
_features,
_rowModels: {},
columns,
data: this._data,
atoms: {
pagination: paginationAtom,
},
manualPagination: true,
})
const pagination = paginationAtom.get()import { createAtom } from '@tanstack/store'
import {
TableController,
rowPaginationFeature,
tableFeatures,
type PaginationState,
} from '@tanstack/lit-table'
const _features = tableFeatures({
rowPaginationFeature,
})
const paginationAtom = createAtom<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const table = this.tableController.table({
_features,
_rowModels: {},
columns,
data: this._data,
atoms: {
pagination: paginationAtom,
},
manualPagination: true,
})
const pagination = paginationAtom.get()When using the atoms option for a slice, you do not need to add the matching on[State]Change option.
The classic state plus on[State]Change pattern is still supported. This can be convenient for simple Lit @state() integrations or when migrating v8 code, but it is less atomic than external atoms.
@state()
private _sorting: SortingState = []
protected render() {
const table = this.tableController.table({
_features,
_rowModels: {
sortedRowModel: createSortedRowModel(sortFns),
},
columns,
data: this._data,
state: {
sorting: this._sorting,
},
onSortingChange: (updater) => {
this._sorting =
updater instanceof Function ? updater(this._sorting) : updater
},
})
return html`...`
}@state()
private _sorting: SortingState = []
protected render() {
const table = this.tableController.table({
_features,
_rowModels: {
sortedRowModel: createSortedRowModel(sortFns),
},
columns,
data: this._data,
state: {
sorting: this._sorting,
},
onSortingChange: (updater) => {
this._sorting =
updater instanceof Function ? updater(this._sorting) : updater
},
})
return html`...`
}The v8-style onStateChange option is no longer part of the v9 TableController state model. v9 encourages keeping table state slices atomic and separated for performance.
The on[State]Change callbacks are useful when you are controlling a matching slice through the state option. They receive either a raw value or an updater function.
If you provide an on[State]Change callback, also provide the corresponding value in state. For example, onSortingChange should be paired with state.sorting.
onPaginationChange: (updater) => {
this._pagination =
updater instanceof Function ? updater(this._pagination) : updater
}onPaginationChange: (updater) => {
this._pagination =
updater instanceof Function ? updater(this._pagination) : updater
}Most complex states in TanStack Table have their own TypeScript types that you can import and use.
import {
TableController,
type PaginationState,
type RowSelectionState,
type SortingState,
type TableState,
} from '@tanstack/lit-table'
@state()
private _sorting: SortingState = [
{
id: 'age',
desc: true,
},
]import {
TableController,
type PaginationState,
type RowSelectionState,
type SortingState,
type TableState,
} from '@tanstack/lit-table'
@state()
private _sorting: SortingState = [
{
id: 'age',
desc: true,
},
]TableState<typeof _features> is inferred from the features registered on that table:
type MyTableState = TableState<typeof _features>type MyTableState = TableState<typeof _features>