Custom React cells
Custom React cells are the main customization tool for changing how a field renders without changing how the table works.
Use them after the column contract is already clear.
Show one realistic table with a linked company cell, status badge, owner/avatar cell, and row action menu visible together.
Custom cells sit on top of the column contract
A custom cell extends the column definition.
The column still decides the durable behavior:
- the stable column ID
- the underlying value source
- filter and sort behavior
- grouping support
- saved-state compatibility
The custom cell decides how that field should appear in the product UI.
That distinction keeps rendering and behavior predictable.
Reach for a custom cell when plain text stops being enough
Custom cells are a good fit when the product needs a field to:
- combine several row fields into one surface
- link to a detail page
- show badges, icons, avatars, or status treatments
- expose a small inline action
- reflect workflow meaning instead of only raw stored values
If the column behavior is wrong, fix the column contract first. If the behavior is right but the UI is too generic, a custom cell is usually the right layer.
The renderer receives row and value context
DatatableColumn supports a cell renderer using the TanStack-style cell context.
import type { DatatableColumn } from "./react-datatable"
type Customer = {
id: string
company: string
owner: string
tier: "free" | "pro" | "enterprise"
}
const columns: DatatableColumn<Customer>[] = [
{
id: "company",
accessorKey: "company",
header: "Company",
filterType: "text",
cell: ({ row, getValue }) => (
<a href={"/customers/" + row.original.id}>
{String(getValue())}
</a>
),
},
]For simple formatting, getValue() is often enough. For richer domain UI, row.original is usually the real source of truth.
Use row.original for multi-field product UI
Many useful cells render small row summaries instead of a single field value.
cell: ({ row }) => (
<div className="min-w-0">
<div className="font-medium">{row.original.company}</div>
<div className="text-muted-foreground text-sm">{row.original.owner}</div>
</div>
)This is the normal pattern when one visual cell needs several row attributes.
The important part is to keep the behavioral column identity stable even when the rendered UI becomes richer.
Keep expensive work outside the renderer
Cells run in the hottest rendering path of the table.
That means the best custom cells are visually rich but computationally boring.
Good pattern:
const money = new Intl.NumberFormat("en", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
})
cell: ({ row }) => money.format(row.original.arr)Less healthy pattern:
- creating formatters on every render
- doing heavy synchronous transforms inside the cell
- deriving large view models repeatedly during scroll
- making network calls from cells
A cell should usually render quickly from data that is already available.
Interactive cells need to cooperate with the table
Buttons, menus, toggles, and links are valid reasons to use custom cells, but they need extra care.
cell: ({ row }) => (
<button aria-label={"Open actions for " + row.original.company}>
Actions
</button>
)When a cell becomes interactive, check that it still coexists cleanly with:
- row selection
- keyboard navigation
- row preview/open-row actions
- focus order
- accessibility labeling
A good custom cell adds product interaction without making the grid feel unpredictable.
Not every rich row detail belongs in a cell
Custom cells are great for compact, high-signal UI.
They are usually the wrong place for:
- long descriptions
- activity feeds
- complex forms
- large stacks of actions
- detail views that need sustained attention
Those heavier experiences generally belong in row preview surfaces or detail routes.
Preserve predictable row height when possible
The table uses virtualization, so custom cells should avoid wildly unstable vertical layout.
A cell can be visually expressive without becoming layout-chaotic.
Prefer:
- compact stacked text
- known-size icons, badges, and avatars
- concise action surfaces
- predictable wrapping rules
Be careful with:
- unbounded text blocks
- async content that changes height dramatically after render
- large controls that make one row much taller than its neighbors
Keep customization local to presentation
A helpful principle is:
- use the column contract to define behavior
- use the custom cell to define presentation
- use row presentation hooks to define state-aware styling
That separation keeps a custom cell from becoming a hidden place where filtering rules, row state, and styling logic all pile up together.
Accessibility checklist for custom cells
Before you call a custom cell done, check that:
- links and buttons have clear text or
aria-label - icon-only controls are still understandable
- focusable elements can be reached by keyboard
- the content still makes sense without color alone
- inline controls do not accidentally block normal table navigation
Use this litmus test
A good custom cell should answer yes to both questions:
- Does this make the product table easier to understand or use?
- Would the table still behave predictably if this cell were duplicated across hundreds of rows?
If the second answer is no, the UI may be too heavy for a cell surface.
Where to go next
- For the behavioral contract underneath the renderer, read Define columns.
- For the higher-level layer map, read Customization overview.
- For copyable renderer patterns, read Custom cells gallery.
- For heavier per-row detail, read Add row preview now and the preview customization page once it lands.