react-datatable
Examples

Avatar select cell

This example builds an owner picker with avatars, search, and an unassigned state.

Live preview

Build the cell component

The main point here is simple: custom cells are just React code. Start by building the cell UI the same way you would build any other React component.

import { Check, ChevronsUpDown } from "lucide-react"
import { useState } from "react"
import { SearchableDropdown } from "@/components/searchable-dropdown"

type Owner = {
  id: string
  name: string
  initials: string
  color: string
}

export function AvatarSelectCell({
  value,
  owners,
  onChange,
}: {
  value: Owner | null
  owners: Owner[]
  onChange: (owner: Owner | null) => void
}) {
  const [open, setOpen] = useState(false)
  const items = [
    { id: "unassigned", name: "Unassigned", initials: "\u2014", color: "#94a3b8" } as Owner,
    ...owners,
  ]

  return (
    <SearchableDropdown
      open={open}
      onOpenChange={setOpen}
      items={items}
      getItemKey={(item) => item.id}
      filterFn={(item, search) => item.name.toLowerCase().includes(search.trim().toLowerCase())}
      onSelect={(item) => onChange(item.id === "unassigned" ? null : item)}
      renderItem={(item, selected) => (
        <span className="flex items-center gap-1.5 text-[12px] leading-4">
          <span
            className="inline-flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
            style={{ backgroundColor: item.color }}
          >
            {item.initials}
          </span>
          <span className="flex-1 truncate">{item.name}</span>
          {selected ? <Check aria-hidden="true" className="ml-auto size-4" /> : null}
        </span>
      )}
      searchPlaceholder="Assign owner..."
      width="w-[240px]"
    >
      <button className="flex h-7 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="inline-flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
          style={{ backgroundColor: value?.color ?? "#cbd5e1" }}
        >
          {value?.initials ?? "\u2014"}
        </span>
        <span className="min-w-0 flex-1 truncate">{value?.name ?? "Unassigned"}</span>
        <ChevronsUpDown aria-hidden="true" className="size-3.5 text-muted-foreground" />
      </button>
    </SearchableDropdown>
  )
}

This component renders the current owner, opens the picker, and calls onChange when someone picks a new value.

SearchableDropdown helper
import { useEffect, useRef } from "react"
import { Input } from "@/components/ui/input"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"

export function SearchableDropdown<T>({
  open,
  onOpenChange,
  items,
  renderItem,
  getItemKey,
  onSelect,
  searchPlaceholder = "Search...",
  filterFn,
  width = "w-56",
  children,
}: SearchableDropdownProps<T>) {
  const searchInputRef = useRef<HTMLInputElement>(null)
  const { search, setSearch, selectedIndex, handleKeyDown, filteredItems } = useDropdownKeyboardNav({
    items,
    onSelect: (item) => {
      onSelect(item)
      onOpenChange(false)
    },
    filterFn,
    onEscape: () => onOpenChange(false),
  })

  useEffect(() => {
    if (!open) {
      setSearch("")
    }
  }, [open, setSearch])

  return (
    <Popover open={open} onOpenChange={onOpenChange}>
      {children && <PopoverTrigger asChild>{children}</PopoverTrigger>}
      <PopoverContent className={width + " z-[100] overflow-hidden p-0"} align="start">
        <div className="flex flex-col" onKeyDown={handleKeyDown}>
          <div className="bg-popover sticky top-0 z-10 border-b p-0.5">
            <Input
              ref={searchInputRef}
              value={search}
              onChange={(event) => setSearch(event.target.value)}
              placeholder={searchPlaceholder}
              className="inline-input h-8 border-none bg-transparent px-2 py-1"
            />
          </div>

          <div className="scrollbar-hidden overflow-y-auto py-1">
            {filteredItems.map((item, index) => (
              <button
                key={getItemKey(item)}
                type="button"
                onClick={() => {
                  onSelect(item)
                  onOpenChange(false)
                }}
                className={index === selectedIndex ? "bg-accent text-accent-foreground mx-1 rounded px-2 py-1.5 text-left" : "mx-1 rounded px-2 py-1.5 text-left hover:bg-accent"}
              >
                {renderItem(item, index === selectedIndex)}
              </button>
            ))}
          </div>
        </div>
      </PopoverContent>
    </Popover>
  )
}

The avatar cell uses a reusable dropdown helper. You do not need this exact helper in your own app, but it is worth showing here because it contains the search, popover, and keyboard behavior that make the picker work.

Connect it to a column

Next, wire the component into the column definition. The cell handles the UI. The column tells the table which field it is rendering.

Even with a custom cell, the column still needs an accessor. In this example that is accessorFn={(row) => row.owner?.name ?? "Unassigned"}. That gives the table a stable field for sorting, filtering, and saved state, while the cell can still read extra data from row.original.
const columns: DatatableColumn<CustomerRow>[] = [
  {
    id: "owner",
    header: "Owner",
    accessorFn: (row) => row.owner?.name ?? "Unassigned",
    width: 220,
    enableSorting: false,
    filterType: "text-list",
    filterOptions: {
      options: owners.map((owner) => ({ value: owner.name, label: owner.name })),
      renderOption: (option) => {
        const owner = owners.find((item) => item.name === option.value)

        return (
          <>
            {owner ? (
              <span
                className="inline-flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
                style={{ backgroundColor: owner.color }}
              >
                {owner.initials}
              </span>
            ) : null}
            <span className="truncate">{option.label}</span>
          </>
        )
      },
    },
    cell: ({ row }) => (
      <AvatarSelectCell
        value={row.original.owner}
        owners={owners}
        onChange={(owner) => handleOwnerChange(row.original.id, owner)}
      />
    ),
  },
]

The cell renderer and the filter option renderer are separate extension points. The cell controls how the value appears inside the grid; filterOptions.renderOption controls how each owner appears in the built-in text-list filter menu.

Full table example

This is the full pattern in one place: the table owns the row state, the custom cell emits the new owner, and the table owns the optimistic update and refetch.

The column order here is deliberate: keep the interactive owner picker near the front so people can actually see and use it without scrolling past low-value fields first.

In the example, useCustomersPage(queryState) stands in for your page-level data hook. That hook gives the table both the current rows and refetchPage(). The cell stays focused on rendering and interaction only.

function CustomersTable() {
  // Your page-level data hook owns the current query and exposes a way to refresh it.
  const { rows: serverRows, refetchPage } = useCustomersPage(queryState)
  const [rows, setRows] = useState(serverRows)

  useEffect(() => {
    setRows(serverRows)
  }, [serverRows])

  const handleOwnerChange = async (id: string, owner: Owner | null) => {
    // Optimistic update: show the new owner in the table immediately.
    setRows((current) => current.map((row) => (row.id === id ? { ...row, owner } : row)))

    try {
      await api.customers.updateOwner({ id, ownerId: owner?.id ?? null })
      await refetchPage() // Re-sync the current query with server truth after the save.
    } catch {
      await refetchPage() // Restore server truth if the mutation failed.
    }
  }

  const columns = [
    { id: "name", header: "Contact", accessorKey: "name", width: 180 },
    {
      id: "owner",
      header: "Owner",
      accessorFn: (row) => row.owner?.name ?? "Unassigned",
      width: 220,
      filterType: "text-list",
      filterOptions: {
        options: owners.map((owner) => ({ value: owner.name, label: owner.name })),
        renderOption: (option) => {
          const owner = owners.find((item) => item.name === option.value)

          return (
            <>
              {owner ? (
                <span
                  className="inline-flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
                  style={{ backgroundColor: owner.color }}
                >
                  {owner.initials}
                </span>
              ) : null}
              <span className="truncate">{option.label}</span>
            </>
          )
        },
      },
      cell: ({ row }) => (
        <AvatarSelectCell
          value={row.original.owner}
          owners={owners}
          onChange={(owner) => handleOwnerChange(row.original.id, owner)}
        />
      ),
    },
    { id: "company", header: "Company", accessorKey: "company", width: 190 },
    { id: "status", header: "Status", accessorKey: "status", width: 140 },
    { id: "health", header: "Health", accessorKey: "health", width: 140 },
    { id: "renewal", header: "Renewal", accessorKey: "renewal", width: 150 },
  ] satisfies DatatableColumn<CustomerRow>[]

  return <Datatable tableKey="custom-cells" data={rows} columns={columns} getRowId={(row) => row.id} toolbar={false} />
}

Full table example

Loading table preferences...

Update flow

This section matters because the cell UI is only half the job. For a real table, readers also need to know where the save logic lives and why the value appears to change immediately.

This example uses the normal production pattern: an optimistic update.

  • the cell emits the new owner
  • the table updates local row state immediately
  • the table sends the mutation in the background
  • on success, the table refetches the current query to sync with server truth
  • on failure, the same refetch restores the correct server state
const handleOwnerChange = async (id: string, owner: Owner | null) => {
  // Optimistic update: update local row state before the request finishes.
  setRows((current) => current.map((row) => (row.id === id ? { ...row, owner } : row)))

  try {
    await api.customers.updateOwner({ id, ownerId: owner?.id ?? null })
    await refetchPage() // Re-run the current table query to sync with server truth.
  } catch {
    await refetchPage() // Roll back to server truth if the save failed.
  }
}

If your table is local-only, you may not need a refetch at all. If it is backed by server data, keep the refetch in the table or page layer so the cell never has to know how persistence works.

On this page