react-datatable
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()
  }
}

On this page