Generating Columns at Runtime
Some tables cannot name every column at build time. An admin viewer may render tenant-defined fields, a debug tool may inspect arbitrary JSON, or a report may receive its visible fields from the first data result.
DynamicColumns is for that case. It waits for the first non-empty data result, calls your render function with that data, and lets you return normal DataTableColumn declarations. The columns stay fixed for the lifetime of that table instance — if the dataset shape changes later, remount the table with a new key.
What the render function receives
Section titled “What the render function receives”The render function is called with { data, model }:
datais the first non-empty array the table captured. It does not change when the model later fetches more rows, filters, or fills in remote placeholders — you scan it once and produce columns from what’s there.modelis the same handle you passed toDataTable, available for the rare case where a generated header or cell needs to publish a model action.
Field scanning lives in your app code so the rules — which fields to include, what counts as a sample value, how to handle group rows — stay obvious next to the table. A general-purpose starting point looks like this:
type RuntimeField = {
field: string
sampleValue: unknown
}
function fieldsFromRows<Row>(rows: readonly Row[], includeField: (field: string) => boolean = () => true): RuntimeField[] {
const fields: RuntimeField[] = []
const fieldByName = new Map<string, RuntimeField>()
const hasSample = new Set<string>()
for (const row of rows) {
if (row === null || typeof row !== 'object' || Array.isArray(row)) {
continue
}
for (const [field, value] of Object.entries(row)) {
if (!includeField(field)) {
continue
}
let descriptor = fieldByName.get(field)
if (!descriptor) {
descriptor = { field, sampleValue: value ?? null }
fieldByName.set(field, descriptor)
fields.push(descriptor)
}
if (!hasSample.has(field) && value !== null && value !== undefined) {
descriptor.sampleValue = value
hasSample.add(field)
}
}
}
return fields
}That helper preserves first-seen field order and returns { field, sampleValue } for each field. sampleValue is the first non-nullish value seen for that field, or null if the field exists but only has nullish values in the captured data.
Local data
Section titled “Local data”Static columns declared around DynamicColumns keep their positions even though the runtime columns are produced between them. This example pins id on the left and actions on the right, and lets the table fill in everything else from the first local result.
import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader } from '@/components/ui/data-table'
import { DynamicColumns, localModel } from '@virtuoso.dev/data-table'
const rows = [
{ id: 'SKU-001', product: 'Standing Desk', category: 'Office', stock: 14, actions: 'Open' },
{ id: 'SKU-002', product: 'USB-C Dock', category: 'Peripherals', stock: 42, region: 'US', actions: 'Open' },
{ id: 'SKU-003', product: 'Mechanical Keyboard', category: 'Peripherals', stock: 28, region: 'EU', actions: 'Open' },
]
type Product = (typeof rows)[number]
const model = localModel<Product>({ data: rows })
function labelFor(field: string) {
return field.replaceAll('_', ' ').replace(/^\w/, (letter) => letter.toUpperCase())
}
function fieldsToShow(rows: readonly Product[]) {
const fields: string[] = []
const seen = new Set<string>()
for (const row of rows) {
for (const field of Object.keys(row)) {
if (field !== 'id' && field !== 'actions' && !seen.has(field)) {
seen.add(field)
fields.push(field)
}
}
}
return fields
}
export default function App() {
return (
<DataTable className="rounded-xl" computeRowKey={({ data }) => data.id} model={model} style={{ height: 300 }}>
<DataTableColumn field="id" sticky="left">
<DataTableColumnHeader className="w-24">SKU</DataTableColumnHeader>
<DataTableCell className="text-muted-foreground tabular-nums">{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
<DynamicColumns<Product>>
{({ data }) =>
fieldsToShow(data).map((field) => (
<DataTableColumn field={field} key={field}>
<DataTableColumnHeader className="min-w-32">{labelFor(field)}</DataTableColumnHeader>
<DataTableCell>{({ cellValue }) => String(cellValue ?? '')}</DataTableCell>
</DataTableColumn>
))
}
</DynamicColumns>
<DataTableColumn field="actions" sticky="right">
<DataTableColumnHeader className="w-24">Actions</DataTableColumnHeader>
<DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
</DataTable>
)
}The key={field} on each generated column matters. Column state such as width, order, visibility, and sticky placement is tied to the field identity.
Remote data
Section titled “Remote data”Remote tables derive their schema from the first non-empty result the same way local tables do. The descriptor helper above is reusable — the sampleValue lets the render prop pick alignment per field:
<DataTable model={model}>
<DataTableColumn field="id" sticky="left" />
<DynamicColumns<ProductRow>>
{({ data }) =>
fieldsFromRows(data, (field) => !['id', 'actions'].includes(field)).map(({ field, sampleValue }) => (
<DataTableColumn field={field} key={field}>
<DataTableColumnHeader>{labelFor(field)}</DataTableColumnHeader>
<DataTableCell {...(typeof sampleValue === 'number' ? { className: 'text-right tabular-nums' } : {})}>
{({ cellValue }) => String(cellValue ?? '')}
</DataTableCell>
</DataTableColumn>
))
}
</DynamicColumns>
<DataTableColumn field="actions" sticky="right" />
</DataTable>The headers appear after the first non-empty result resolves. Later pages or viewport fetches do not add more columns — that avoids mid-scroll layout changes, but it means the first captured result must contain every field you want to show.
When the backend can return column metadata alongside the rows, prefer that. Metadata can render headers before any rows arrive, works for empty responses, and can carry labels, alignment, and visibility hints out of band.
Placeholders
Section titled “Placeholders”The field scanner has no way to tell offset-mode placeholders apart from real rows — it treats them as ordinary data. Placeholder-only fields show up in the discovered column list, and fields that exist only on real rows go missing if the first captured result is all placeholders.
Make placeholders schema-compatible with real rows:
const placeholder = {
id: '',
product: '',
category: '',
stock: null,
actions: '',
}Do not put placeholder-only fields in the visible schema. If a placeholder needs technical fields for rendering, filter them with includeField. If the real field list cannot be known until the backend responds, return column metadata from the backend or wait for a real data result instead of relying on placeholder shape.
Grouped data
Section titled “Grouped data”Grouped models mix group-header rows and body rows in the same array. The field scanner treats them the same way, so any field on a group-header row (a groupLabel, a level number, a count) would become a body column unless you filter group rows out first:
const fields = fieldsFromRows(
data.filter((row) => !(typeof row === 'object' && row !== null && 'groupLabel' in row)),
(field) => field !== 'id'
)Render group labels with DataTableGroupHeader; keep runtime columns focused on ordinary body rows.
Rediscovering columns
Section titled “Rediscovering columns”DynamicColumns is intentionally one-shot. A user action that changes row values should not reshuffle the table’s columns.
When a user intentionally switches to a different dataset shape, remount the table:
<DataTable key={datasetId} model={model}>
<DynamicColumns>{/* declare columns from the new first result */}</DynamicColumns>
</DataTable>A user-facing column-management UI — letting the user add, remove, or reorder fields after mount — is a different problem. Keep the field list in your own React state and map it to DataTableColumn declarations directly; DynamicColumns doesn’t help here, and trying to bend it to that shape leads to the temporal bugs the one-shot rule was designed to avoid.
When this does not fit
Section titled “When this does not fit”- Backend-defined schemas. Return column metadata alongside the rows when headers must render before data, when valid responses can be empty, or when labels and alignment come from server configuration.
- Statically-known schemas. Use a static field list when the schema is known in code but the JSX would be repetitive — see Generated columns.
- Runtime column-management UI. Lift the field list into your own React state when the user can add, remove, or reorder fields after mount.
DynamicColumns fits when the schema is data-defined, the first non-empty result is representative, and remounting the table is acceptable for re-discovery.