Examples
Single select cell
Use this for compact states like status, stage, or lifecycle where a small dropdown is enough.
Interactive cell
Live preview
Build the React component
This is a good example of a cell that feels product-specific while still being ordinary React code.
import { Check } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
const toneByStatus = {
Active: "#10b981",
Trial: "#8b5cf6",
Paused: "#f59e0b",
}
const statuses = ["Active", "Trial", "Paused"] as const
type Status = (typeof statuses)[number]
export function SingleSelectCell({
value,
onChange,
}: {
value: Status
onChange: (value: Status) => void
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex w-full items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-[12px] leading-4 hover:bg-accent" type="button">
<span className="size-2 rounded-full" style={{ backgroundColor: toneByStatus[value] }} />
<span>{value}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
{statuses.map((status) => (
<DropdownMenuItem key={status} className="text-[12px] leading-4" onClick={() => onChange(status)}>
<span className="size-2 rounded-full" style={{ backgroundColor: toneByStatus[status] }} />
<span>{status}</span>
{status === value ? <Check aria-hidden="true" className="ml-auto size-4" /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}Wire it into a column definition
Keep the finite state field anchored to the column contract, then let the cell own the compact dropdown treatment.
Even with a custom cell, the column still needs an accessor. In this example that is
accessorKey="status". That gives the table a stable field for sorting, filtering, and saved state, while the cell can still read extra data from row.original.{
id: "status",
header: "Status",
accessorKey: "status",
width: 140,
enableSorting: false,
enableFiltering: false,
cell: ({ row }) => (
<SingleSelectCell
value={row.original.status}
onChange={(status) => updateRow(row.original.id, { status })}
/>
),
}Render it in a small table
The demo table uses one custom status column and plain surrounding columns so the pattern stays easy to scan.
function StatusExampleTable() {
const [rows, setRows] = useState(seedRows)
const updateRow = (id: string, patch: Partial<CustomerRow>) => {
setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)))
}
return <Datatable tableKey="custom-cells" data={rows} columns={columns(updateRow)} getRowId={(row) => row.id} toolbar={false} />
}Mini table demo
Loading table preferences...
Persist changes with optimistic update
If status changes should sync to a backend, optimistic update first and let the server remain authoritative after the mutation settles.
async function updateStatus(id: string, status: Status) {
setRows((current) => current.map((row) => (row.id === id ? { ...row, status } : row)))
try {
await api.customers.updateStatus({ id, status })
await refetchCustomers()
} catch {
await refetchCustomers()
}
}