Column Visibility
Column visibility has two flavours: a declarative default that hides a column at mount, and a runtime channel that flips visibility from a “Columns” menu, a saved view, or a similar toggle. The two compose — declare hidden defaults for columns like internal IDs, audit timestamps, and other secondary metadata, then expose a runtime control for users who need to opt back in.
import { Button } from '@/components/ui/button'
import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader } from '@/components/ui/data-table'
import { columns$, localModel, useEngineRef, useRemoteCellValue, useRemotePublisher } from '@virtuoso.dev/data-table'
import { columnVisibilityState$, resetColumnVisibility$, setColumnVisibility$ } from '@virtuoso.dev/data-table/column-visibility'
const rows = [
{ product: 'Standing Desk', category: 'Office', stock: 14, internalId: 'inv-001' },
{ product: 'USB-C Dock', category: 'Peripherals', stock: 42, internalId: 'inv-002' },
{ product: 'Mechanical Keyboard', category: 'Peripherals', stock: 28, internalId: 'inv-003' },
]
function labelFor(field: string) {
return field.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^\w/, (letter) => letter.toUpperCase())
}
const model = localModel({ data: rows })
export default function App() {
const engineRef = useEngineRef()
const columns = useRemoteCellValue(columns$, engineRef)
const visibility = useRemoteCellValue(columnVisibilityState$, engineRef)
const setColumnVisibility = useRemotePublisher(setColumnVisibility$, engineRef)
const resetColumnVisibility = useRemotePublisher(resetColumnVisibility$, engineRef)
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2 text-sm">
{[...(columns ?? new Map())].map(([key, column]) => {
const visible = visibility?.get(key) ?? column.visible !== false
return (
<label key={key} className="flex items-center gap-2">
<input checked={visible} onChange={() => setColumnVisibility({ key, visible: !visible })} type="checkbox" />
{labelFor(column.field)}
</label>
)
})}
<Button onClick={() => resetColumnVisibility()} size="sm" variant="outline">
Reset
</Button>
</div>
<DataTable className="rounded-xl" model={model} engineRef={engineRef} style={{ height: 280 }}>
<DataTableColumn field="product">
<DataTableColumnHeader>Product</DataTableColumnHeader>
<DataTableCell className="font-medium">{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
<DataTableColumn field="category">
<DataTableColumnHeader>Category</DataTableColumnHeader>
<DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
<DataTableColumn field="stock">
<DataTableColumnHeader className="justify-end">Stock</DataTableColumnHeader>
<DataTableCell className="text-right tabular-nums">{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
<DataTableColumn field="internalId" visible={false}>
<DataTableColumnHeader>Internal ID</DataTableColumnHeader>
<DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
</DataTable>
</div>
)
}Hiding at declaration time
Section titled “Hiding at declaration time”Pass visible={false} to a DataTableColumn and it stays out of the rendered grid:
<DataTableColumn field="internalId" visible={false}>
<DataTableColumnHeader>Internal ID</DataTableColumnHeader>
<DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>The column is still registered — that’s what makes it selectable in a picker. It just doesn’t take a slot in the header, body, or scroll measurement. There’s no layout cost beyond keeping its JSX in the tree, so this is also the right shape for “hidden by default, sometimes useful” columns that don’t need a runtime toggle.
Toggling at runtime
Section titled “Toggling at runtime”Publish to setColumnVisibility$ (from @virtuoso.dev/data-table/column-visibility) with a runtime column key and the boolean you want the column to end up at:
setColumnVisibility({ key, visible: !isVisible })Read the current state from columnVisibilityState$ — a Map<key, boolean> with one entry per registered column, declarative defaults already folded in. Consumers don’t have to fall back to columns$ for “what’s the default again?”; the resolved value is right there.
resetColumnVisibility$ (a Trigger, no payload) clears every override at once — typically wired to a “Reset to defaults” button. And there’s a quieter convenience worth knowing: when the user toggles a column back to its declarative default, the override is dropped instead of stored as a redundant entry. “Hide then show” returns the table to a clean state, and the saved-state side of the feature stays minimal as a result.
Where the controls live
Section titled “Where the controls live”The demo above sits beside the table and connects through engineRef, using useRemoteCellValue and useRemotePublisher. That’s the common shape — a toolbar, a preset menu, a settings panel — anywhere the controls aren’t a child of <DataTable>.
For controls mounted inside the table tree — a header slot, a footer, a context menu rendered from a cell — the simpler useCellValue and usePublisher hooks from @virtuoso.dev/reactive-engine-react work, picking up the same engine through React context:
import { columns$ } from '@virtuoso.dev/data-table'
import { columnVisibilityState$, setColumnVisibility$ } from '@virtuoso.dev/data-table/column-visibility'
import { useCellValue, usePublisher } from '@virtuoso.dev/reactive-engine-react'
function ColumnVisibilityPicker() {
const columns = useCellValue(columns$)
const visibility = useCellValue(columnVisibilityState$)
const setColumnVisibility = usePublisher(setColumnVisibility$)
return (
<div>
{[...columns].map(([key, column]) => {
const visible = visibility.get(key)
return (
<button key={key} onClick={() => setColumnVisibility({ key, visible: !visible })} type="button">
{visible ? 'Hide' : 'Show'} {column.field}
</button>
)
})}
</div>
)
}Controlling the Table covers the in-tree-vs-sibling distinction in depth.
How visibility composes with other column features
Section titled “How visibility composes with other column features”- Groups. Hiding a column shrinks its
ColumnGroupheader band to whatever children remain visible. Hide every column in a group and the group header disappears with them — there’s no empty band. - Sticky. A hidden sticky column simply vanishes; the columns next to it stay where they are rather than sliding into the pinned region.
- Reorder. Visibility and order are independent — hiding a column doesn’t change its position in
columns$, and showing it again restores it to the slot it held before.
Saving the user’s choice
Section titled “Saving the user’s choice”columnVisibilityPersistenceAdapter() plugs into State Persistence to save visibility across reloads. The saved shape is keyed by stable field name and contains only the differences from declarative defaults, so:
- A user who hasn’t touched the picker saves nothing.
- A column that started visible and stays visible never appears in saved state.
- Hiding a default-visible column writes one entry; un-hiding it drops the entry back out.
That makes the saved bundle forward-compatible: adding a new column to the JSX with a sensible declarative default doesn’t surprise existing users with a stale override on next load. The catch sits on the other side — renaming a field invalidates the saved visibility for that field, since the saved state can no longer find the column it was about. The field-name persistence contract covers the rules around renames.
See the Column Visibility example for a runnable sibling-toolbar setup.