react-datatable
Examples

Multi-select cell

Use this when one field needs a small set of labels, tags, or categories without expanding into a full editor.

Interactive cell

Live preview

Build the React component

The main job here is summarizing several values into one compact surface and exposing a small add/remove interaction.

import { Check, Plus } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import {
  Command,
  CommandEmpty,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"

type Tag = {
  id: string
  name: string
  color: string
}

export function MultiSelectCell({
  value,
  options,
  onChange,
}: {
  value: Tag[]
  options: Tag[]
  onChange: (value: Tag[]) => void
}) {
  const selectedIds = new Set(value.map((tag) => tag.id))

  return (
    <Popover>
      <PopoverTrigger asChild>
        <button className="flex min-h-8 w-full flex-wrap items-center gap-1 rounded-md px-1.5 py-1 text-left hover:bg-accent" type="button">
          {value.length === 0 ? (
            <span className="inline-flex items-center gap-1 text-sm text-muted-foreground">
              <Plus className="size-3.5" /> Add tags
            </span>
          ) : (
            <>
              {value.slice(0, 2).map((tag) => (
                <Badge className="gap-1 rounded-full" key={tag.id} variant="secondary">
                  <span className="size-1.5 rounded-full" style={{ backgroundColor: tag.color }} />
                  {tag.name}
                </Badge>
              ))}
            </>
          )}
        </button>
      </PopoverTrigger>
      <PopoverContent align="start" className="w-[260px] p-0">
        <Command>
          <CommandInput placeholder="Add tags..." />
          <CommandList>
            <CommandEmpty>No tags found.</CommandEmpty>
            {options.map((tag) => {
              const selected = selectedIds.has(tag.id)
              return (
                <CommandItem
                  key={tag.id}
                  onSelect={() => {
                    const next = selected ? value.filter((item) => item.id !== tag.id) : [...value, tag]
                    onChange(next)
                  }}
                  value={tag.name}
                >
                  <span className="inline-flex size-4 items-center justify-center rounded-sm border border-border">
                    {selected ? <Check className="size-3" /> : null}
                  </span>
                  <span className="size-2 rounded-full" style={{ backgroundColor: tag.color }} />
                  <span>{tag.name}</span>
                </CommandItem>
              )
            })}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}

Wire it into a column definition

The important detail is the accessor: the column still exposes a stable string representation for table semantics even though the UI renders badges from richer objects.

Even with a custom cell, the column still needs an accessor. In this example that is accessorFn={(row) => row.tags.map((tag) => tag.name).join(", ")}. 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: "tags",
  header: "Tags",
  accessorFn: (row) => row.tags.map((tag) => tag.name).join(", "),
  width: 260,
  enableSorting: false,
  enableFiltering: false,
  cell: ({ row }) => (
    <MultiSelectCell
      value={row.original.tags}
      options={tagOptions}
      onChange={(tags) => updateRow(row.original.id, { tags })}
    />
  ),
}

Render it in a small table

The table example keeps only the tags column custom so you can see how the badge picker sits inside a normal Datatable.

function TagsExampleTable() {
  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

For server sync, treat the selected tags as a small optimistic patch and then refetch the authoritative row.

async function updateTags(id: string, tags: Tag[]) {
  setRows((current) => current.map((row) => (row.id === id ? { ...row, tags } : row)))

  try {
    await api.customers.updateTags({ id, tagIds: tags.map((tag) => tag.id) })
    await refetchCustomers()
  } catch {
    await refetchCustomers()
  }
}

On this page