Composable tables are app-level table factories built with createTableHook. They let React apps define shared features, row models, default options, and reusable components once, then create multiple tables from that shared setup.
Use this pattern when several tables should share the same behavior and component conventions. For one standalone table, useTable 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/react-table'
import {
PaginationControls,
RowCount,
TableToolbar,
} from '../components/table-components'
import {
CategoryCell,
NumberCell,
PriceCell,
ProgressCell,
RowActionsCell,
StatusCell,
TextCell,
} from '../components/cell-components'
import {
ColumnFilter,
FooterColumnId,
FooterSum,
SortIndicator,
} from '../components/header-components'
const features = tableFeatures({
columnFilteringFeature,
rowPaginationFeature,
rowSortingFeature,
})
export const {
createAppColumnHelper,
useAppTable,
useTableContext,
useCellContext,
useHeaderContext,
} = createTableHook({
features,
rowModels: {
sortedRowModel: createSortedRowModel(sortFns),
filteredRowModel: createFilteredRowModel(filterFns),
paginatedRowModel: createPaginatedRowModel(),
},
getRowId: (row) => row.id,
tableComponents: {
PaginationControls,
RowCount,
TableToolbar,
},
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/react-table'
import {
PaginationControls,
RowCount,
TableToolbar,
} from '../components/table-components'
import {
CategoryCell,
NumberCell,
PriceCell,
ProgressCell,
RowActionsCell,
StatusCell,
TextCell,
} from '../components/cell-components'
import {
ColumnFilter,
FooterColumnId,
FooterSum,
SortIndicator,
} from '../components/header-components'
const features = tableFeatures({
columnFilteringFeature,
rowPaginationFeature,
rowSortingFeature,
})
export const {
createAppColumnHelper,
useAppTable,
useTableContext,
useCellContext,
useHeaderContext,
} = createTableHook({
features,
rowModels: {
sortedRowModel: createSortedRowModel(sortFns),
filteredRowModel: createFilteredRowModel(filterFns),
paginatedRowModel: createPaginatedRowModel(),
},
getRowId: (row) => row.id,
tableComponents: {
PaginationControls,
RowCount,
TableToolbar,
},
cellComponents: {
TextCell,
NumberCell,
StatusCell,
ProgressCell,
RowActionsCell,
PriceCell,
CategoryCell,
},
headerComponents: {
SortIndicator,
ColumnFilter,
FooterColumnId,
FooterSum,
},
})| Helper | Purpose |
|---|---|
| useAppTable | Creates a table with shared features, row models, defaults, and registered components. |
| createAppColumnHelper | Creates column helpers with TFeatures and registered component types already bound. |
| useTableContext | Reads the current table inside registered table components. |
| useCellContext | Reads the current cell inside registered cell components. |
| useHeaderContext | Reads the current header/footer inside registered header components. |
Create one column helper per row type. Since the helper is already bound to the app table setup, column definitions can reference registered cell and header components directly.
const personColumnHelper = createAppColumnHelper<Person>()
const columns = useMemo(
() =>
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 = useMemo(
() =>
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 />,
}),
]),
[],
)Registered cell components use useCellContext() internally, and registered header/footer components use useHeaderContext().
Create each table with useAppTable. You pass table-specific options like key, columns, and data; the shared features, rowModels, getRowId, and component registries come from the hook.
const table = useAppTable(
{
key: 'users-table',
columns,
data,
debugTable: true,
},
(state) => state,
)const table = useAppTable(
{
key: 'users-table',
columns,
data,
debugTable: true,
},
(state) => state,
)The returned table includes AppTable, AppHeader, AppCell, and AppFooter wrappers. The example uses AppTable with a selector so rendering can subscribe to the state slices used by that table.
<table.AppTable
selector={(state) => ({
pagination: state.pagination,
sorting: state.sorting,
columnFilters: state.columnFilters,
})}
>
{({ sorting, columnFilters }) => (
<div className="table-container">
<table.TableToolbar title="Users Table" onRefresh={refreshData} />
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((h) => (
<table.AppHeader header={h} key={h.id}>
{(header) => (
<th onClick={header.column.getToggleSortingHandler()}>
<header.FlexRender />
<header.SortIndicator />
<header.ColumnFilter />
</th>
)}
</table.AppHeader>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getAllCells().map((c) => (
<table.AppCell cell={c} key={c.id}>
{(cell) => (
<td>
<cell.FlexRender />
</td>
)}
</table.AppCell>
))}
</tr>
))}
</tbody>
</table>
<table.PaginationControls />
<table.RowCount />
</div>
)}
</table.AppTable><table.AppTable
selector={(state) => ({
pagination: state.pagination,
sorting: state.sorting,
columnFilters: state.columnFilters,
})}
>
{({ sorting, columnFilters }) => (
<div className="table-container">
<table.TableToolbar title="Users Table" onRefresh={refreshData} />
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((h) => (
<table.AppHeader header={h} key={h.id}>
{(header) => (
<th onClick={header.column.getToggleSortingHandler()}>
<header.FlexRender />
<header.SortIndicator />
<header.ColumnFilter />
</th>
)}
</table.AppHeader>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getAllCells().map((c) => (
<table.AppCell cell={c} key={c.id}>
{(cell) => (
<td>
<cell.FlexRender />
</td>
)}
</table.AppCell>
))}
</tr>
))}
</tbody>
</table>
<table.PaginationControls />
<table.RowCount />
</div>
)}
</table.AppTable>The example creates both personColumnHelper and productColumnHelper from the same createAppColumnHelper, then renders Users and Products tables with the same useAppTable factory. Each table owns its data and columns, while the app hook owns table infrastructure and component conventions.