Composable tables are app-level table factories built with createTableHook. They let Lit apps define shared features, row models, default options, and reusable render helpers once, then create multiple tables from that setup.
Use this pattern when several tables should share behavior and cell/header rendering conventions. For one standalone Lit table, TableController is usually enough.
The composable tables example keeps the shared configuration in src/hooks/table.ts.
import {
columnFilteringFeature,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
createTableHook,
filterFns,
rowPaginationFeature,
rowSortingFeature,
sortFns,
tableFeatures,
} from '@tanstack/lit-table'
import {
CategoryCell,
NumberCell,
PriceCell,
ProgressCell,
RowActionsCell,
StatusCell,
TextCell,
} from '../components/cell-components'
import {
ColumnFilter,
FooterColumnId,
FooterSum,
SortIndicator,
} from '../components/header-components'
export const features = tableFeatures({
columnFilteringFeature,
rowPaginationFeature,
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
filteredRowModel: createFilteredRowModel(),
paginatedRowModel: createPaginatedRowModel(),
sortFns,
filterFns,
})
export const { createAppColumnHelper, useAppTable, useTableContext } =
createTableHook({
features,
getRowId: (row) => row.id,
cellComponents: {
TextCell,
NumberCell,
StatusCell,
ProgressCell,
RowActionsCell,
PriceCell,
CategoryCell,
},
headerComponents: {
SortIndicator,
ColumnFilter,
FooterColumnId,
FooterSum,
},
})import {
columnFilteringFeature,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
createTableHook,
filterFns,
rowPaginationFeature,
rowSortingFeature,
sortFns,
tableFeatures,
} from '@tanstack/lit-table'
import {
CategoryCell,
NumberCell,
PriceCell,
ProgressCell,
RowActionsCell,
StatusCell,
TextCell,
} from '../components/cell-components'
import {
ColumnFilter,
FooterColumnId,
FooterSum,
SortIndicator,
} from '../components/header-components'
export const features = tableFeatures({
columnFilteringFeature,
rowPaginationFeature,
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
filteredRowModel: createFilteredRowModel(),
paginatedRowModel: createPaginatedRowModel(),
sortFns,
filterFns,
})
export const { createAppColumnHelper, useAppTable, useTableContext } =
createTableHook({
features,
getRowId: (row) => row.id,
cellComponents: {
TextCell,
NumberCell,
StatusCell,
ProgressCell,
RowActionsCell,
PriceCell,
CategoryCell,
},
headerComponents: {
SortIndicator,
ColumnFilter,
FooterColumnId,
FooterSum,
},
})The Lit example does not register tableComponents in createTableHook. Its table-level controls are custom elements that call useTableContext(this), so they consume table context directly.
| Helper | Purpose |
|---|---|
| useAppTable | Creates a TableController-backed app table for a Lit host and attaches app render helpers. |
| createAppColumnHelper | Creates column helpers with TFeatures and registered cell/header component types already bound. |
| useTableContext | Lets custom elements like pagination-controls read the nearest app table context. |
Create one column helper per row type. Cell/header components in Lit are functions, so column definitions call the registered function on the enhanced cell or header.
const personColumnHelper = createAppColumnHelper<Person>()
const columns = personColumnHelper.columns([
personColumnHelper.accessor('firstName', {
header: 'First Name',
footer: (props) => props.column.id,
cell: ({ cell }) => cell.TextCell(),
}),
personColumnHelper.accessor('age', {
header: 'Age',
footer: (props) => props.column.id,
cell: ({ cell }) => cell.NumberCell(),
}),
personColumnHelper.display({
id: 'actions',
header: 'Actions',
cell: ({ cell }) => cell.RowActionsCell(),
}),
])const personColumnHelper = createAppColumnHelper<Person>()
const columns = personColumnHelper.columns([
personColumnHelper.accessor('firstName', {
header: 'First Name',
footer: (props) => props.column.id,
cell: ({ cell }) => cell.TextCell(),
}),
personColumnHelper.accessor('age', {
header: 'Age',
footer: (props) => props.column.id,
cell: ({ cell }) => cell.NumberCell(),
}),
personColumnHelper.display({
id: 'actions',
header: 'Actions',
cell: ({ cell }) => cell.RowActionsCell(),
}),
])Call useAppTable(this, options, selector) from the LitElement host. The helper returns an object with table(), which computes the current app table through the controller.
private appTable = (() => {
const host = this
return useAppTable(
this,
{
columns,
get data() {
return host.data
},
debugTable: true,
},
(state) => ({
pagination: state.pagination,
sorting: state.sorting,
columnFilters: state.columnFilters,
}),
)
})()private appTable = (() => {
const host = this
return useAppTable(
this,
{
columns,
get data() {
return host.data
},
debugTable: true,
},
(state) => ({
pagination: state.pagination,
sorting: state.sorting,
columnFilters: state.columnFilters,
}),
)
})()Inside render(), use callback-based app wrappers:
const table = this.appTable.table()
return html`
<table-toolbar .title=${'Users Table'}></table-toolbar>
<table>
<thead>
${table.getHeaderGroups().map(
(headerGroup) => html`
<tr>
${headerGroup.headers.map((h) =>
table.AppHeader(
h,
(header) => html`
<th @click=${header.column.getToggleSortingHandler()}>
${header.FlexRender()} ${header.SortIndicator()}
${header.ColumnFilter()}
</th>
`,
),
)}
</tr>
`,
)}
</thead>
<tbody>
${table.getRowModel().rows.map(
(row) => html`
<tr>
${row
.getAllCells()
.map((cell) =>
table.AppCell(cell, (appCell) => html`<td>${appCell.FlexRender()}</td>`),
)}
</tr>
`,
)}
</tbody>
</table>
<pagination-controls></pagination-controls>
<row-count></row-count>
`const table = this.appTable.table()
return html`
<table-toolbar .title=${'Users Table'}></table-toolbar>
<table>
<thead>
${table.getHeaderGroups().map(
(headerGroup) => html`
<tr>
${headerGroup.headers.map((h) =>
table.AppHeader(
h,
(header) => html`
<th @click=${header.column.getToggleSortingHandler()}>
${header.FlexRender()} ${header.SortIndicator()}
${header.ColumnFilter()}
</th>
`,
),
)}
</tr>
`,
)}
</thead>
<tbody>
${table.getRowModel().rows.map(
(row) => html`
<tr>
${row
.getAllCells()
.map((cell) =>
table.AppCell(cell, (appCell) => html`<td>${appCell.FlexRender()}</td>`),
)}
</tr>
`,
)}
</tbody>
</table>
<pagination-controls></pagination-controls>
<row-count></row-count>
`The Users and Products table elements import the same createAppColumnHelper and useAppTable from src/hooks/table.ts. Their data and columns differ, but sorting, filtering, pagination, row IDs, and registered cell/header renderers come from one shared configuration.