Capítulo 20 de 54

Chapter 20: Table and Column Meta Guide

Core Idea

meta is a free-form, typed pass-through slot — TanStack Table never reads or writes it — for carrying your own context (an updateData callback, a filterVariant tag) anywhere the table/column instance is available. v9 adds a per-table typed way to declare its shape (metaHelper + tableMeta/columnMeta/filterMeta slots on tableFeatures()), replacing v8's global declaration merging.

Key Concepts

  • Two (really three) meta surfaces: meta table option → read anywhere via table.options.meta (e.g. an updateData(rowIndex, columnId, value) callback passed down to editable cells); meta on a column definition → read via column.columnDef.meta (e.g. filterVariant: 'range' telling a header which filter UI to render); filterMeta → metadata a custom filter function attaches per-row via addMeta(...) during filtering (e.g. a fuzzy-match ranking score), read back via row.columnFiltersMeta[columnId] in a paired sort function or cell renderer.
  • Typing per-table (v9, recommended): declare interfaces for your meta shapes, then register them as phantom slots — tableFeatures({ ..., tableMeta: metaHelper<MyTableMeta>(), columnMeta: metaHelper<MyColumnMeta>() }). From then on, typeof features carries the meta types everywhere (useTable, createColumnHelper, ColumnDef, Column) with zero extra generics.
  • metaHelper<T>() just returns {} cast to T — you could write {} as MyTableMeta yourself, but metaHelper reads as type-only at a glance and avoids a @typescript-eslint/no-unnecessary-type-assertion false positive (whose autofix would silently delete the cast) when your meta type is all-optional properties.
  • These slots are phantom: only the TypeScript type matters. At runtime the value is stripped from the table's registered features — it's not a real feature, and the actual meta values still flow through the ordinary meta table option / column-def meta property exactly as before.
  • Scoping is per-table: different tables built from different features objects can declare completely different meta shapes — unlike v8, there's no single global TableMeta/ColumnMeta interface every table shares.
  • v8-style global declaration merging still works (declare module '@tanstack/react-table' { interface TableMeta<TFeatures, TData> {...} }) — the only v9 change is that TFeatures is now the first generic on both interfaces. Precedence: a features object with its own tableMeta/columnMeta slot replaces (not merges with) the global declaration-merged interface for tables built from it; tables whose features declare no slot fall back to the global interfaces.
  • When meta isn't enough: meta is just a typed bag of values — it has no defaults, no state, no instance methods. If you want real new table options with defaults, new state, or new APIs on the table instance (e.g. table.toggleDensity()), write a custom feature instead (see Custom Features Guide); it plugs into the same features option with the same type inference.

Code Examples

interface MyTableMeta { updateData: (rowIndex: number, columnId: string, value: unknown) => void }
interface MyColumnMeta { filterVariant?: 'text' | 'range' | 'select' }

const features = tableFeatures({
  rowSortingFeature,
  tableMeta: metaHelper<MyTableMeta>(),
  columnMeta: metaHelper<MyColumnMeta>(),
})

const table = useTable({
  features, columns, data,
  meta: { updateData: (rowIndex, columnId, value) => { /* ... */ } },
})

// anywhere the table/column is available, fully typed:
table.options.meta?.updateData(0, 'age', 42)
column.columnDef.meta?.filterVariant // 'text' | 'range' | 'select' | undefined
  • What it demonstrates: declaring the meta shape once via tableFeatures gives full type inference everywhere downstream, with the actual values still passed through the ordinary meta options.

Worked Example

filterMeta for a fuzzy-filter ranking score — a custom filter function computes a rank via addMeta, and a paired sort function reads it back through row.columnFiltersMeta[columnId] to sort by relevance instead of raw value:

interface FuzzyFilterMeta { itemRank?: RankingInfo }
type FuzzyFeatures = TableFeatures & { filterMeta: FuzzyFilterMeta }

const fuzzyFilter: FilterFn<FuzzyFeatures, RowData> = (row, columnId, value, addMeta) => {
  const itemRank = rankItem(row.getValue(columnId), value)
  addMeta({ itemRank })          // stash the rank for this row/column
  return itemRank.passed
}

const fuzzySort: SortFn<FuzzyFeatures, Person> = (rowA, rowB, columnId) => {
  const rank = rowA.columnFiltersMeta[columnId]
  return rank ? compareItems(rank.itemRank!, rowB.columnFiltersMeta[columnId]?.itemRank!) : 0
}

const features = tableFeatures({
  columnFilteringFeature, rowSortingFeature,
  filterFns: { fuzzy: fuzzyFilter },
  sortFns: { fuzzy: fuzzySort },
  filterMeta: metaHelper<FuzzyFilterMeta>(),
})

This is the mechanism behind "sort by search relevance after a fuzzy filter" — a pattern that has no other clean way to pass per-row filter by-products into a sort comparator.

Key Takeaways

  1. Reach for the per-table metaHelper/tableMeta/columnMeta slots by default in v9 — global declaration merging is legacy-compatible, not the recommended path.
  2. filterMeta is specifically for custom filter functions that need to hand a by-product (a ranking score, a match span) to a sort function or renderer.
  3. Meta is a dumb pass-through, not a feature — once you want state, defaults, or table-instance methods, write a custom feature instead.

Connects To

  • Custom Features (React) Guide: the escalation path when meta isn't expressive enough.
  • Fuzzy Filtering (React) Guide: the full fuzzy-filter recipe this chapter's worked example previews.
  • Column Definitions Guide: where column-def meta is set alongside accessorKey/cell/etc.