Skip to main content
This guide covers changes to the beta DataTable component. These APIs may continue to evolve before the stable release.

Overview

The select and multiselect edit modes have been updated to use SelectMenuSync and MultiSelectMenuSync internally, replacing the previous Menu and Popover+SearchField+ListView implementations. The options shape and onChange signature for both modes have changed to align with how SelectMenu and MultiSelectMenu work elsewhere in the design system.

Design Rationale

Why replace Menu and Popover with SelectMenu?

The previous implementations used basic primitives that lacked features available in the SelectMenu and MultiSelectMenu components:
  • No built-in search field in the select dropdown
  • No support for disabled options
  • Inconsistent keyboard navigation compared to other select experiences in the product
  • Separate codepaths to maintain for the same conceptual UI
By delegating to SelectMenuSync and MultiSelectMenuSync, the DataTable’s select cells gain all of these features automatically, and the behavior is now consistent with select inputs elsewhere.

Why option objects instead of raw primitives?

The previous options shape used { value, label } where value was typed as the column’s data type. This created a tight coupling between options and row data types, and required extra mapping logic in onChange. The new shape uses { id, label } — the same shape as SelectMenuOption and MultiSelectMenuOption — which aligns with how options are represented across the design system and eliminates the need for type gymnastics.

Migration Guide

Select edit mode

Before:
createColumn("status", {
  headerLabel: "Status",
  editConfig: {
    mode: "select",
    options: [
      { value: "open", label: "Open" },
      { value: "closed", label: "Closed" },
    ],
    onChange: (value, rowId) => {
      // value is "open" | "closed" (raw primitive)
      updateStatus(value, rowId);
    },
  },
});
After:
createColumn("status", {
  headerLabel: "Status",
  editConfig: {
    mode: "select",
    options: [
      { id: "open", label: "Open" },
      { id: "closed", label: "Closed" },
    ],
    onChange: (option, rowId) => {
      // option is SelectMenuOption | null
      if (option) updateStatus(String(option.id), rowId);
    },
  },
});

Multiselect edit mode

Before:
createColumn("tags", {
  headerLabel: "Tags",
  editConfig: {
    mode: "multiselect",
    options: [
      { value: "frontend", label: "Frontend" },
      { value: "backend", label: "Backend" },
    ],
    onChange: (value, rowId) => {
      // value is string[] (raw primitives)
      updateTags(value, rowId);
    },
  },
});
After:
createColumn("tags", {
  headerLabel: "Tags",
  editConfig: {
    mode: "multiselect",
    options: [
      { id: "frontend", label: "Frontend" },
      { id: "backend", label: "Backend" },
    ],
    onChange: (options, rowId) => {
      // options is MultiSelectMenuOption[]
      updateTags(options.map((o) => String(o.id)), rowId);
    },
  },
});

Live selection in multiselect

The previous multiselect cell buffered selections and only committed on Tab or F2. The new implementation calls onChange live with each selection toggle, matching how MultiSelectField works in forms. Pressing Escape closes the popover without reverting — the last onChange call reflects the committed state.

Breaking Changes

The options shape and onChange signature for select and multiselect edit modes have changed. Existing usage of these modes must be updated.
The following changes are breaking:
  • SelectEditConfig.options — Changed from { value: T[K]; label: string }[] to SelectMenuOption[] (uses id instead of value)
  • SelectEditConfig.onChange — Changed from (value: T[K], rowId: string) => void to (option: SelectMenuOption | null, rowId: string) => void
  • MultiselectEditConfig.options — Changed from { value: ArrayElement<T[K]>; label: string }[] to MultiSelectMenuOption[] (uses id instead of value)
  • MultiselectEditConfig.onChange — Changed from (value: T[K], rowId: string) => void to (options: MultiSelectMenuOption[], rowId: string) => void
  • Multiselect commit behavioronChange is now called live on each selection toggle rather than only on Tab or F2

Why breaking?

Since DataTable is a beta component, we chose to align the API with the rest of the design system rather than maintain backward compatibility. The option object shape ({ id, label }) is consistent with every other select experience in Anvil2, and the onChange signatures match those of SelectMenu and MultiSelectMenu. Both select and multiselect edit configs accept most props from SelectMenuSync and MultiSelectMenuSync to customize the underlying menu behavior. The only excluded props are those the DataTable cell manages itself (trigger, label, value, onSelectedOptionChange / onSelectedOptionsChange, options, onMenuKeyDown, onImplicitClose, onExplicitClose). Common customizations:
createColumn("status", {
  headerLabel: "Status",
  editConfig: {
    mode: "select",
    options: [...],
    onChange: (option, rowId) => { ... },
    disableSearch: true,             // Hide the search field
    searchPlaceholder: "Find status...",
    width: 240,                      // Popover width in pixels (default: 320)
    displayMenuAs: "popover",        // Force popover on mobile too (default: "auto")
    virtualize: true,                // Enable windowed rendering for large option lists
    pinned: { ... },                 // Pin options to the top of the list
    filter: myCustomFilterFn,        // Custom client-side filter/sort logic
    groupToString: (g) => String(g), // Label formatter for grouped options
  },
});
For mode: "multiselect", the selectAll and selectFiltered props are also available to add bulk-selection controls:
createColumn("tags", {
  headerLabel: "Tags",
  editConfig: {
    mode: "multiselect",
    options: [...],
    onChange: (options, rowId) => { ... },
    selectAll: true,          // Adds a "Select All" option at the top
    selectFiltered: true,     // Adds a "Select Filtered" option when searching
  },
});
When omitted, defaults are displayMenuAs: "auto" (dialog on mobile, popover on desktop) and width: 320.

Async Select and Multiselect

Two new async edit modes are available: "select-async" and "multiselect-async". These use SelectMenu and MultiSelectMenu directly (not the Sync variants) and accept a loadOptions function instead of a static options array.

When to use async modes

  • Options are fetched from an API rather than known at render time
  • Different rows need different option sets (per-row async loading)
  • Option lists are large enough to benefit from server-side filtering

loadOptions and row context

The loadOptions function receives a context argument as its last parameter containing { row, rowId }. This enables per-row option loading without any additional API surface:
// Column-level async (context unused):
createColumn("status", {
  headerLabel: "Status",
  editConfig: {
    mode: "select-async",
    loadOptions: (search) => api.fetchStatuses(search),
    onChange: (option, rowId) => {
      if (option) updateStatus(String(option.id), rowId);
    },
  },
});

// Per-row async (context used):
createColumn("status", {
  headerLabel: "Status",
  editConfig: {
    mode: "select-async",
    loadOptions: (search, { row }) => api.fetchAllowedStatuses(row.jobType, search),
    onChange: (option, rowId) => {
      if (option) updateStatus(String(option.id), rowId);
    },
  },
});

Lazy variants

All four SelectMenu lazy variants are supported. Set lazy to choose the pagination strategy:
// Page-based pagination
createColumn("status", {
  headerLabel: "Status",
  editConfig: {
    mode: "select-async",
    lazy: "page",
    lazyOptions: { pageSize: 20 },
    loadOptions: (search, page, pageSize, { row }) =>
      api.fetchStatuses(row.region, search, page, pageSize),
    onChange: (option, rowId) => { ... },
  },
});
lazy valueLoader signature
false (default)(search, context)
"page"(search, pageNumber, pageSize, context)
"offset"(search, offset, limit, context)
"group"(search, previousGroupKey, context)

Cache behavior

By default, caching is disabled (cache={{ enabled: false }}) to prevent cross-row cache pollution when loadOptions uses row context. To enable caching (for column-level loaders that don’t depend on row data), pass cache explicitly:
editConfig: {
  mode: "select-async",
  cache: { enabled: true },
  loadOptions: (search) => api.fetchStatuses(search),
  onChange: (option, rowId) => { ... },
},

Multiselect async value contract

Unlike mode: "multiselect" (which stores an array of IDs), mode: "multiselect-async" requires the cell value to be MultiSelectMenuOption[] — full option objects. The onChange callback receives the full objects and the consumer stores them wholesale:
// Row data type must have the field typed as MultiSelectMenuOption[]
type RowData = {
  id: string;
  tags: MultiSelectMenuOption[];
};

createColumn("tags", {
  headerLabel: "Tags",
  editConfig: {
    mode: "multiselect-async",
    loadOptions: (search) => api.searchTags(search),
    onChange: (options, rowId) => {
      // options is MultiSelectMenuOption[] — store directly
      updateTags(options, rowId);
    },
  },
});
This eliminates the async reconciliation problem (no need to reconstruct full option objects from IDs when the option list isn’t yet loaded).

New Exports

The following types are now used by these edit modes and are exported from @servicetitan/anvil2/beta:
  • SelectMenuOption — Option type for mode: "select" and mode: "select-async" columns
  • MultiSelectMenuOption — Option type for mode: "multiselect" and mode: "multiselect-async" columns
  • AsyncCellContext — Context type passed to loadOptions in async edit configs ({ row: T; rowId: string })
  • SelectAsyncCellEagerLoader — Loader type for mode: "select-async" with lazy: false
  • SelectAsyncCellPageLoader — Loader type for mode: "select-async" with lazy: "page"
  • SelectAsyncCellOffsetLoader — Loader type for mode: "select-async" with lazy: "offset"
  • SelectAsyncCellGroupLoader — Loader type for mode: "select-async" with lazy: "group"
  • MultiselectAsyncCellEagerLoader — Loader type for mode: "multiselect-async" with lazy: false
  • MultiselectAsyncCellPageLoader — Loader type for mode: "multiselect-async" with lazy: "page"
  • MultiselectAsyncCellOffsetLoader — Loader type for mode: "multiselect-async" with lazy: "offset"
  • MultiselectAsyncCellGroupLoader — Loader type for mode: "multiselect-async" with lazy: "group"
Last modified on April 2, 2026