| name | lit/lit-table-controller |
| description | The `TableController` ReactiveController pattern for hosting a TanStack Table instance inside a LitElement. One controller per host (constructed in a class field); `.table(options, selector?)` called from `render()`. The controller installs the Lit `coreReactivityFeature`, subscribes the host to `table.store` and `table.optionsStore`, and tears those subscriptions down on `hostDisconnected`. Routing keywords: TableController, ReactiveController, ReactiveControllerHost, hostConnected, hostDisconnected, lit-table.
|
| type | framework |
| library | tanstack-table |
| framework | lit |
| library_version | 9.0.0-alpha.48 |
| requires | ["lit/table-state"] |
| sources | ["TanStack/table:docs/framework/lit/lit-table.md","TanStack/table:docs/framework/lit/guide/table-state.md","TanStack/table:packages/lit-table/src/TableController.ts","TanStack/table:packages/lit-table/src/subscribe-directive.ts","TanStack/table:packages/lit-table/src/reactivity.ts","TanStack/table:examples/lit/basic-table-controller/src/main.ts"] |
Maintainer note: the Lit adapter now runs on @tanstack/lit-store. TableController still wires host invalidation to the full table.store/table.optionsStore; the new table.subscribe(source, selector?, template) directive (replacing the old table.Subscribe helper) is what narrows re-rendering to a single template region. Further changes may land during the v9 beta cycle.
TableController is the Lit-specific entry point for @tanstack/lit-table. It implements the Lit ReactiveController interface, hosts the underlying core Table instance, and bridges TanStack Store atom changes to host.requestUpdate() calls. This skill explains the lifecycle in detail.
What TableController actually does
export class TableController<TFeatures, TData> implements ReactiveController {
constructor(host: ReactiveControllerHost) {
;(this.host = host).addController(this)
}
public table(tableOptions, selector?) {
if (!this._table) {
this._table = constructTable({
...tableOptions,
features: {
coreReactivityFeature: litReactivity(),
...tableOptions.features,
},
mergeOptions: (def, next) => ({ ...def, ...next }),
})
this._setupSubscriptions()
}
this._table.setOptions((prev) => ({ ...prev, ...tableOptions }))
return {
}
}
private _setupSubscriptions() {
this._storeSubscription = this._table.store.subscribe(() =>
this.host.requestUpdate(),
)
this._optionsSubscription = this._table.optionsStore!.subscribe(() =>
this.host.requestUpdate(),
)
}
hostConnected() {
this._setupSubscriptions()
}
hostDisconnected() {
this._storeSubscription?.unsubscribe()
this._optionsSubscription?.unsubscribe()
}
}
Source: packages/lit-table/src/TableController.ts (full file).
Key points:
- One core table per controller. The first
.table(options) call constructs it; later calls merge options into the same instance.
- Two subscriptions:
table.store (state) and table.optionsStore (options). Both call host.requestUpdate().
- Subscriptions are torn down on
hostDisconnected and reset on hostConnected.
- The host re-renders on any store change.
TableController itself does not split invalidation by source. To narrow re-rendering to a single template region, wrap it in the table.subscribe(source, selector?, template) directive, which updates only that region when its selected value's reference changes (identity comparison). See the lit/table-state skill, Core Pattern 4.
Lifecycle Diagram
constructor render() hostDisconnected
│ │ │
▼ ▼ ▼
host.addController(this) this.tableController.table(opts) unsubscribe(store)
│ unsubscribe(options)
▼
(first call) constructTable(opts) + _setupSubscriptions
(later calls) table.setOptions(prev => ({ ...prev, ...opts }))
│
▼
returns { ...table, subscribe, FlexRender, state }
Canonical Setup
import { LitElement, html } from 'lit'
import { customElement, state } from 'lit/decorators.js'
import { repeat } from 'lit/directives/repeat.js'
import {
FlexRender,
TableController,
tableFeatures,
type ColumnDef,
} from '@tanstack/lit-table'
const features = tableFeatures({})
const columns: Array<ColumnDef<typeof features, Person>> = [
]
@customElement('people-table')
class PeopleTable extends LitElement {
private tableController = new TableController<typeof features, Person>(this)
@state()
private data: Person[] = []
protected render() {
const table = this.tableController.table(
{
features,
columns,
data: this.data,
},
() => ({}),
)
return html`
<table>
<thead>
${repeat(
table.getHeaderGroups(),
(hg) => hg.id,
(hg) => html`
<tr>
${repeat(
hg.headers,
(h) => h.id,
(h) => html`<th>${FlexRender({ header: h })}</th>`,
)}
</tr>
`,
)}
</thead>
<tbody>
${repeat(
table.getRowModel().rows,
(r) => r.id,
(row) => html`
<tr>
${repeat(
row.getAllCells(),
(c) => c.id,
(cell) => html`<td>${FlexRender({ cell })}</td>`,
)}
</tr>
`,
)}
</tbody>
</table>
`
}
}
Source: examples/lit/basic-table-controller/src/main.ts.
Multiple Tables in One Host
Each table needs its own controller. Don't try to share one across instances.
class DashboardElement extends LitElement {
private peopleController = new TableController<
typeof _peopleFeatures,
Person
>(this)
private projectsController = new TableController<
typeof _projectsFeatures,
Project
>(this)
protected render() {
const people = this.peopleController.table({
})
const projects = this.projectsController.table({
})
return html`
<people-section .data=${people}></people-section>
<projects-section .data=${projects}></projects-section>
`
}
}
Reading State Off the Controller
The controller's .table(...) return value carries everything you usually need: feature methods, FlexRender, Subscribe, and the state projection. Direct reads off table.atoms.<slice>.get() and table.state.<slice> are current-value reads; reactivity comes from the host invalidation subscriptions the controller already wires up.
const pagination = table.atoms.pagination.get()
const snapshot = table.state
const selected = table.state
Common Mistakes
CRITICAL Creating a new TableController per render
Wrong:
protected render() {
const controller = new TableController<typeof features, Person>(this)
const table = controller.table({ })
}
Correct: construct the controller once as a class field.
private tableController = new TableController<typeof features, Person>(this)
protected render() {
const table = this.tableController.table({ })
}
Each new controller installs another subscription on the host; old controller state is discarded; table state resets every frame.
Source: packages/lit-table/src/TableController.ts.
CRITICAL Calling .table() outside render() and caching the return value
Wrong:
connectedCallback() {
super.connectedCallback()
this.cachedTable = this.tableController.table({ features, columns, data: this.data })
}
protected render() {
return html`${this.cachedTable.getRowModel().rows.map(/* … */)}`
}
Correct: call .table() each render(). The options are merged into the same logical table on each call, and the returned object carries fresh state/projections.
protected render() {
const table = this.tableController.table({ features, columns, data: this.data })
return html`${table.getRowModel().rows.map(/* … */)}`
}
Source: packages/lit-table/src/TableController.ts.
HIGH Expecting table.subscribe to stop the host from re-rendering
Wrong: assuming table.subscribe(table.atoms.rowSelection, …) makes the host invalidate only on selection changes.
Correct: the host's requestUpdate() is wired to the full table.store and table.optionsStore, so render() still runs on every store change. What table.subscribe narrows is the wrapped template region: it skips re-rendering that region (and re-running its template function) when its selected slice is unchanged. Wrap expensive regions (large tbody lists) in table.subscribe with a stable selector to get the benefit.
Source: packages/lit-table/src/TableController.ts (_setupSubscriptions); packages/lit-table/src/subscribe-directive.ts.
HIGH Building features inside render()
Wrong:
protected render() {
const features = tableFeatures({ rowSortingFeature })
const table = this.tableController.table({ features, })
}
Correct: declare features at module scope (or once on the class, frozen). Identity drives internal memos.
const features = tableFeatures({ rowSortingFeature })
Source: docs/framework/lit/guide/table-state.md (FAQ #1).
MEDIUM Calling .table() from a non-host context (e.g. a child component)
Wrong: passing the controller down and calling .table() from a different LitElement. The subscriptions belong to the host that constructed the controller — calling from elsewhere is undefined behavior.
Correct: each LitElement that needs its own table builds its own controller. Use createTableHook's useTableContext / useCellContext / useHeaderContext (@lit/context) to access a table from descendant elements.
Source: packages/lit-table/src/createTableHook.ts.
See Also
tanstack-table/lit/table-state — atoms, Subscribe, FlexRender, createTableHook.
tanstack-table/lit/getting-started — first-table walkthrough.
tanstack-table/lit/compose-with-tanstack-virtual — pairing with @tanstack/lit-virtual.