Skip to main content
Beta FeatureThis feature is currently in beta, and needs to be imported from @servicetitan/anvil2/beta.While we hope to minimize breaking changes, they may occur due to feedback we receive or other improvements. These will always be documented in the changelog and communicated in Slack.Please reach out in the #ask-designsystem channel with any questions or feedback!

Overview

The select field family includes two components for different use cases:
  • SelectField — For async data loading with support for pagination (page-based, offset-based, or group-based lazy loading)
    • Includes automatic debouncing of the search input (configurable via the debounceMs prop)
    • Includes automatic caching of the search input (configurable via the cache prop)
  • SelectFieldSync — For client-side filtering of static option arrays
Both components provide a searchable dropdown interface and adaptive display modes (popover or dialog).

SelectFieldSync (Static Options)

Use SelectFieldSync when you have a static list of options that can be filtered client-side.
import { useState } from "react";
import { SelectFieldSync } from "@servicetitan/anvil2/beta";

const options = [
  { id: 1, label: "Option One" },
  { id: 2, label: "Option Two" },
  { id: 3, label: "Option Three" },
];

const ExampleComponent = () => {
  const [selectedOption, setSelectedOption] = useState(null);

  return (
    <SelectFieldSync
      label="Select an option"
      placeholder="Search options..."
      options={options}
      value={selectedOption}
      onSelectedOptionChange={setSelectedOption}
    />
  );
};

Custom Filtering

By default, SelectFieldSync uses match-sorter to filter options by their label and searchText fields. You can customize filtering in two ways:

Using match-sorter options

<SelectFieldSync
  options={options}
  filter={{ keys: ["label", "searchText"] }}
  // ...other props
/>

Using a custom filter function

<SelectFieldSync
  options={options}
  filter={(options, searchValue) => {
    return options.filter((option) =>
      option.label?.toLowerCase().includes(searchValue.toLowerCase())
    );
  }}
  // ...other props
/>

SelectField (Async Loading)

Use SelectField when options need to be fetched from an API or when dealing with large datasets that require server-side filtering.

Basic Async Loading

import { useState } from "react";
import { SelectField } from "@servicetitan/anvil2/beta";

const ExampleComponent = () => {
  const [selectedOption, setSelectedOption] = useState(null);

  return (
    <SelectField
      label="Search users"
      placeholder="Type to search..."
      loadOptions={async (searchValue) => {
        const response = await fetch(`/api/users?q=${searchValue}`);
        const users = await response.json();
        return users.map((user) => ({
          id: user.id,
          label: user.name,
        }));
      }}
      value={selectedOption}
      onSelectedOptionChange={setSelectedOption}
    />
  );
};

Lazy Loading Modes

SelectField supports three lazy loading modes for paginated data:

Page-based Pagination

<SelectField
  lazy="page"
  lazyOptions={{ pageSize: 10 }}
  loadOptions={async (searchValue, pageNumber, pageSize) => {
    const response = await fetch(
      `/api/items?q=${searchValue}&page=${pageNumber}&size=${pageSize}`
    );
    const { data, totalCount } = await response.json();
    return {
      options: data.map((item) => ({ id: item.id, label: item.name })),
      hasMore: pageNumber * pageSize + data.length < totalCount,
    };
  }}
  // ...other props
/>

Offset-based Pagination

<SelectField
  lazy="offset"
  lazyOptions={{ limit: 15 }}
  loadOptions={async (searchValue, offset, limit) => {
    const response = await fetch(
      `/api/items?q=${searchValue}&offset=${offset}&limit=${limit}`
    );
    const { data, totalCount } = await response.json();
    return {
      options: data.map((item) => ({ id: item.id, label: item.name })),
      hasMore: offset + limit < totalCount,
    };
  }}
  // ...other props
/>

Group-based Loading

For loading grouped options incrementally:
<SelectField
  lazy="group"
  groupToString={(groupValue) => String(groupValue)}
  loadOptions={async (searchValue, previousGroupKey) => {
    const response = await fetch(
      `/api/items?q=${searchValue}&afterGroup=${previousGroupKey || ""}`
    );
    const { data, hasMore } = await response.json();
    return {
      options: data.map((item) => ({
        id: item.id,
        label: item.name,
        group: item.category, // Required for grouped options
      })),
      hasMore,
    };
  }}
  // ...other props
/>

Display Modes

Control how the options menu is displayed using the displayMenuAs prop:
// Automatically choose based on device (default)
<SelectFieldSync displayMenuAs="auto" {...props} />

// Always show as popover (not recommended for mobile)
<SelectFieldSync displayMenuAs="popover" {...props} />

// Always show as dialog
<SelectFieldSync displayMenuAs="dialog" {...props} />

Caching

SelectField caches loadOptions results by default. Configure caching behavior:
// Disable caching
<SelectField cache={{ enabled: false }} {...props} />

// Configure max cache size (default: 15)
<SelectField cache={{ maxSize: 100 }} {...props} />

Clearing the Cache

Use a ref to imperatively clear the cache:
import { useRef } from "react";
import { SelectField } from "@servicetitan/anvil2/beta";

const ExampleComponent = () => {
  const selectFieldRef = useRef(null);

  const handleRefresh = () => {
    selectFieldRef.current?.clearCache();
  };

  return (
    <>
      <SelectField ref={selectFieldRef} {...props} />
      <button onClick={handleRefresh}>Refresh Options</button>
    </>
  );
};

Initial Load Behavior

Control when options are first loaded with the initialLoad prop:
// Load immediately on mount (default)
<SelectField initialLoad="immediate" {...props} />

// Load when user opens the dropdown
<SelectField initialLoad="open" {...props} />

// Auto (currently equivalent to "immediate")
<SelectField initialLoad="auto" {...props} />

Field States

Error State

Display validation errors using the error prop:
<SelectFieldSync
  label="Category"
  options={options}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  error="Please select a category"
/>

Hint and Description

Provide additional context with hint and description:
<SelectFieldSync
  label="Category"
  options={options}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  hint="Select the most relevant category"
  description="This will be used for filtering"
/>

Required Field

Mark a field as required with the required prop:
<SelectFieldSync
  label="Category"
  options={options}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  required
/>

Disabled and ReadOnly

When disabled is set, users cannot interact with the field.
// Disabled - cannot interact with the field
<SelectFieldSync
  label="Category"
  options={options}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  disabled
/>
When readOnly is set, users can see what options exist but cannot change the current value.
// ReadOnly - can see options but cannot change the current value
<SelectFieldSync
  label="Category"
  options={options}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  readOnly
/>

Prefix and Suffix

Add content before or after the input with prefix and suffix:
<SelectFieldSync
  label="Price Range"
  options={priceRanges}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  prefix="$"
  suffix="USD"
/>

Disabled Options

Individual options can be disabled by setting disabled: true on the option:
const options = [
  { id: 1, label: "Available Option" },
  { id: 2, label: "Unavailable Option", disabled: true },
  { id: 3, label: "Another Available Option" },
];

<SelectFieldSync
  label="Select an option"
  options={options}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
/>
Last modified on January 23, 2026