Sometimes you need to attach your own arbitrary data or functions to a table or its columns so that they are available anywhere the table or column instances are available. That is what the meta options are for. TanStack Table never reads or writes meta itself; it is purely a place for you to pass your own context through the table.
There are two kinds of meta:
const table = useTable({
features,
rowModels: {},
columns,
data,
meta: {
updateData: (rowIndex, columnId, value) => {
// ...
},
},
})
// ...later, anywhere the table is available (e.g. inside a cell component)
table.options.meta?.updateData(rowIndex, columnId, newValue)const table = useTable({
features,
rowModels: {},
columns,
data,
meta: {
updateData: (rowIndex, columnId, value) => {
// ...
},
},
})
// ...later, anywhere the table is available (e.g. inside a cell component)
table.options.meta?.updateData(rowIndex, columnId, newValue)Column meta is set on the column definition and is identical across every adapter:
const columns = columnHelper.columns([
columnHelper.accessor('age', {
header: 'Age',
meta: {
filterVariant: 'range',
},
}),
])
// ...later, anywhere a column is available (e.g. inside a header component)
const variant = column.columnDef.meta?.filterVariantconst columns = columnHelper.columns([
columnHelper.accessor('age', {
header: 'Age',
meta: {
filterVariant: 'range',
},
}),
])
// ...later, anywhere a column is available (e.g. inside a header component)
const variant = column.columnDef.meta?.filterVariantBy default, both meta types are empty objects, so to get type safety you declare their shapes yourself. New in v9, you can declare meta types per features set with the type-only tableMeta and columnMeta slots in your tableFeatures() call, using the metaHelper utility.
First, define the shapes you want (this is the same in every framework):
interface MyTableMeta {
updateData: (rowIndex: number, columnId: string, value: unknown) => void
}
interface MyColumnMeta {
filterVariant?: 'text' | 'range' | 'select'
}interface MyTableMeta {
updateData: (rowIndex: number, columnId: string, value: unknown) => void
}
interface MyColumnMeta {
filterVariant?: 'text' | 'range' | 'select'
}Then declare them on your features object:
import { metaHelper, rowSortingFeature, tableFeatures } from '@tanstack/react-table'
const features = tableFeatures({
rowSortingFeature,
tableMeta: metaHelper<MyTableMeta>(),
columnMeta: metaHelper<MyColumnMeta>(),
})import { metaHelper, rowSortingFeature, tableFeatures } from '@tanstack/react-table'
const features = tableFeatures({
rowSortingFeature,
tableMeta: metaHelper<MyTableMeta>(),
columnMeta: metaHelper<MyColumnMeta>(),
})That's it. Everywhere this features object flows (useTable, createColumnHelper, ColumnDef, Column, and so on), the meta types are inferred from typeof features with no extra generics to pass around:
const columnHelper = createColumnHelper<typeof features, Person>()
columnHelper.accessor('age', {
meta: {
filterVariant: 'range', // ✅ type-checked against MyColumnMeta
},
})
// And both meta surfaces are fully typed wherever you read them:
table.options.meta?.updateData // ✅ (rowIndex, columnId, value) => void
column.columnDef.meta?.filterVariant // ✅ 'text' | 'range' | 'select' | undefinedconst columnHelper = createColumnHelper<typeof features, Person>()
columnHelper.accessor('age', {
meta: {
filterVariant: 'range', // ✅ type-checked against MyColumnMeta
},
})
// And both meta surfaces are fully typed wherever you read them:
table.options.meta?.updateData // ✅ (rowIndex, columnId, value) => void
column.columnDef.meta?.filterVariant // ✅ 'text' | 'range' | 'select' | undefinedUnlike the v8-style declaration merging described below, this scoping is per-table, not global: only tables created with this features object get these meta types. Different tables in your app can declare entirely different meta shapes by using different features objects.
The tableMeta and columnMeta keys are phantom entries: only their TypeScript types matter. At runtime, the value is an empty object that gets stripped from the table's registered features, so it is never treated as a real feature. The actual meta values are still passed where they always were: the meta table option and the meta property on column definitions.
metaHelper<MyMeta>() simply returns {} cast to your meta type. You can write the cast yourself instead:
const features = tableFeatures({
rowSortingFeature,
tableMeta: {} as MyTableMeta,
columnMeta: {} as MyColumnMeta,
})const features = tableFeatures({
rowSortingFeature,
tableMeta: {} as MyTableMeta,
columnMeta: {} as MyColumnMeta,
})Both forms are equivalent. Prefer metaHelper: it reads as type-only at a glance, and it avoids false positives from the @typescript-eslint/no-unnecessary-type-assertion lint rule, which flags the {} as form when your meta type has only optional properties (and whose auto-fix would silently erase your meta type).
The v8 approach of extending the TableMeta and ColumnMeta interfaces with module augmentation still works in v9. The only change from v8 is the generics shape: TFeatures is now the first type parameter on both interfaces.
import type { CellData, RowData, TableFeatures } from '@tanstack/react-table'
declare module '@tanstack/react-table' {
interface TableMeta<TFeatures extends TableFeatures, TData extends RowData> {
updateData: (rowIndex: number, columnId: string, value: unknown) => void
}
interface ColumnMeta<
TFeatures extends TableFeatures,
TData extends RowData,
TValue extends CellData = CellData,
> {
filterVariant?: 'text' | 'range' | 'select'
}
}import type { CellData, RowData, TableFeatures } from '@tanstack/react-table'
declare module '@tanstack/react-table' {
interface TableMeta<TFeatures extends TableFeatures, TData extends RowData> {
updateData: (rowIndex: number, columnId: string, value: unknown) => void
}
interface ColumnMeta<
TFeatures extends TableFeatures,
TData extends RowData,
TValue extends CellData = CellData,
> {
filterVariant?: 'text' | 'range' | 'select'
}
}The trade-off with declaration merging is that it is global. Every table in your entire project gets the same meta types, whether or not a given table actually provides that meta. If you have many tables with different needs, the per-table slots above are a better fit.
The two approaches resolve with a simple precedence: if a features object declares a tableMeta or columnMeta slot, that slot's type is used for tables created with those features, replacing (not merging with) the globally declared interface. Tables whose features declare no slot fall back to the declaration-merged interfaces.
Meta is intentionally simple: a typed bag of values you carry through the table. If you find yourself wanting real table options with defaults, new state, or new APIs on the table instance (e.g. table.toggleDensity()), consider writing a custom feature instead. Custom features plug into the same features option, get the same typeof features type inference, and can declare their own options, state, and instance methods. Meta was never designed to do any of that.