Reusable Columns
Column definitions are the most useful reuse boundary in the table. A column can own its accessor, header, cell renderer, sorting/filtering configuration, metadata, and domain-specific presentation.
The constraint is that columns are table configuration, so they should have stable references. Reusable columns work best when the column owns static structure and delegates changing workflow state to the table, cell components, or domain layer.
Column Modules
The problem appears when a generated column closes over changing React state: edited values, pending flags, permissions, or callbacks from the current render.
In this example, the table has an editable status column. editedValues stores unsaved cell drafts, and isSaving disables the control while a save is in flight. Assume row IDs are already stable. The problem here is the column dependency.
function UsersTable({data}: {data: User[]}) {
const [editedValues, setEditedValues] = useState<
Record<string, Partial<User>>
>({})
const [isSaving, setIsSaving] = useState(false)
const columns = useMemo<ColumnDef<User>[]>(
() => [
{
accessorKey: "status",
cell: ({getValue, row}) => (
<StatusSelect
disabled={isSaving}
value={editedValues[row.id]?.status ?? getValue()}
onValueChange={(status) =>
setEditedValues((values) => ({
...values,
[row.id]: {...values[row.id], status},
}))
}
/>
),
},
],
[editedValues, isSaving],
)
// ...
}This fixes stale reads, but it makes the column array change on every edit. Each setEditedValues call creates a new object, so useMemo returns new column definitions. When the column configuration changes, the entire table is recomputed. This is a big performance issue.
There are a few solutions to this problem. The one we'll explore today is scoping state updates to the column's cell component.
Preferred Pattern
The following example shows the same boundary using Jotai. The table creates a scoped Jotai store around its implementation. A custom cell component taps into this store and reads/writes the state to the atom. All while the column definition stays static.
This keeps the column responsible for column structure only, and offloads the data concern to the inner cell component.
import {atom, Provider} from "jotai"
import {atomFamily} from "jotai-family"
const userDraftAtom = atomFamily((rowId: string) => atom<Partial<User>>({}))
const savingUserIdsAtom = atom<ReadonlySet<string>>(new Set())
const isUserSavingAtom = atomFamily((rowId: string) =>
atom((get) => get(savingUserIdsAtom).has(rowId)),
)
interface StatusCellProps {
rowId: string
value: User["status"]
}
function EditableStatusCell({rowId, value}: StatusCellProps) {
const [draft, setDraft] = useAtom(userDraftAtom(rowId))
const isSaving = useAtomValue(isUserSavingAtom(rowId))
const status = draft.status ?? value
return (
<StatusSelect
disabled={isSaving}
value={status}
onValueChange={(nextStatus) =>
setDraft((current) => ({...current, status: nextStatus}))
}
/>
)
}
function UsersTable(props: {data: User[]}) {
return (
<Provider>
<UsersTableInner {...props} />
</Provider>
)
}
function UsersTableInner({data}: {data: User[]}) {
const columns = useMemo<ColumnDef<User>[]>(
() => [
columnHelper.accessor("status", {
header: "Status",
cell: ({getValue, row}) => (
<EditableStatusCell rowId={row.id} value={getValue()} />
),
}),
],
[],
)
const table = useReactTable({
columns,
data,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
})
// ... rendered table
}EditableStatusCell is now owned by the table that needs editing. It can use that screen's provider, atoms, query/mutation hooks, or form logic. The reusable column does not know about editedValues, isSaving, or updateEditedValue.
You could repeat the same approach with React Context instead of Jotai for the same effect.
This keeps the column array memoized with an empty dependency list because the column structure is static. The changing edit state stays in the workflow that owns the table.
When to update column definitions
You typically only update column definitions when a value changes which columns exist or how static column options are configured:
const columns = useMemo<ColumnDef<User>[]>(
() =>
[
makeNameColumn(),
canEdit ? makeEditableStatusColumn() : makeReadonlyStatusColumn(),
showActions ? makeActionsColumn() : null,
].filter((column): column is ColumnDef<User> => column !== null),
[canEdit, showActions],
)Cell Content
Concern
Tables repeat the same cell presentations: truncated text, badges, editable controls, action buttons, links, dates, empty values, and validation states. Those pieces are worth reusing, but they should remain small and typed. The following is an example of where this falls apart.
Pitfall
Centralizing all cell presentation behind one generic renderer creates another table abstraction.
function CellContent({
type,
value,
row,
options,
}: {
type: string
value: unknown
row: unknown
options?: Record<string, unknown>
}): ReactNode {
switch (type) {
case "status":
return <StatusBadge status={value as Status} />
case "editableText":
return <TextInput value={value as string} />
case "date":
return formatDate(value)
case "actions":
return <ActionMenu actions={options?.actions as Action[]} row={row} />
default:
return <span className="truncate">{String(value)}</span>
}
}This starts as reuse, but the switchboard weakens types, hides dependencies, and couples every new cell type to the central renderer.
Preferred Pattern
Prefer small, typed components that each own one presentation concern:
function TextCell({value}: {value: string | null | undefined}) {
return <span className="truncate">{value || "-"}</span>
}
function StatusCell({status}: {status: User["status"]}) {
return <StatusBadge status={status} />
}
function RowActionsCell({user}: {user: User}) {
return <UserActionsMenu user={user} />
}Column modules compose those helpers directly from the table render context:
function makeUserColumns(): ColumnDef<User>[] {
return [
columnHelper.accessor("name", {
header: "Name",
cell: ({getValue}) => <TextCell value={getValue()} />,
}),
columnHelper.accessor("status", {
header: "Status",
cell: ({getValue}) => <StatusCell status={getValue()} />,
}),
columnHelper.display({
id: "actions",
cell: ({row}) => <RowActionsCell user={row.original} />,
}),
]
}The table renderer should still own the actual table cell markup:
<Table.Cell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Table.Cell>Use typed content components, not a single renderer for all cell types. Give each helper a real component API instead of generic type, options, and row: unknown inputs.