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:<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
/>
<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}
/>
Pinned Options
Pin frequently used or suggested options above the list using the pinned prop. Each pinned section requires a label and an options value, which can be a static array or a dynamic loader function.Static Pinned Options
Pass an object with label and a static options array:<SelectField
label="Books"
pinned={{
label: "Favorites",
options: [
{ id: "fav-1", label: "The Martian" },
{ id: "fav-2", label: "Dune" },
],
}}
loadOptions={fetchBooks}
value={selectedOption}
onSelectedOptionChange={setSelectedOption}
/>
Dynamic Pinned Options
Pass a function as options to compute pinned options based on the current search value. This is useful for AI-powered suggestions or context-aware recommendations:<SelectField
label="Books"
pinned={{
label: "AI Suggestions",
options: async (searchValue) => {
const suggestions = await fetchAISuggestions(searchValue);
return suggestions.map((s) => ({ id: s.id, label: s.title }));
},
}}
loadOptions={fetchBooks}
value={selectedOption}
onSelectedOptionChange={setSelectedOption}
/>
By default, the loader re-runs whenever the search value changes. Set searchReactive: false to call the loader once and reuse the result across all search values:<SelectField
label="Books"
pinned={{
label: "Your Favorites",
options: async () => {
return await fetchFavorites();
},
searchReactive: false,
}}
loadOptions={fetchBooks}
value={selectedOption}
onSelectedOptionChange={setSelectedOption}
/>
Multiple Pinned Sections
Pass an array of pinned section objects:<SelectField
label="Books"
pinned={[
{
label: "AI Suggestions",
options: async (searchValue) => {
const suggestions = await fetchAISuggestions(searchValue);
return suggestions.map((s) => ({ id: s.id, label: s.title }));
},
},
{
label: "Your Favorites",
options: [
{ id: "fav-1", label: "Dune" },
{ id: "fav-2", label: "Foundation" },
],
},
]}
loadOptions={fetchBooks}
value={selectedOption}
onSelectedOptionChange={setSelectedOption}
/>
Grouping Options
Options can be organized into visual groups by adding a group property to each option. Groups appear as labeled sections in the dropdown.Basic Grouping
Add a group property to options and provide a groupToString function to display group labels:const options = [
{ id: 1, label: "Apple", group: "fruits" },
{ id: 2, label: "Banana", group: "fruits" },
{ id: 3, label: "Carrot", group: "vegetables" },
{ id: 4, label: "Broccoli", group: "vegetables" },
];
<SelectFieldSync
label="Food"
options={options}
groupToString={(group) =>
group === "fruits" ? "Fruits" : "Vegetables"
}
value={selectedOption}
onSelectedOptionChange={setSelectedOption}
/>
Group Sorting
Use groupSorter to control the order of groups. By default, groups appear in the order they are first encountered in the options array. With groupSorter, you can apply custom sorting (e.g., alphabetical, numerical priority).This prop is available on SelectFieldSync and non-lazy SelectField:Avoid using groupSorter with lazy="group". When groups load incrementally from the server and get re-sorted, the menu content shifts unexpectedly, creating a disorienting user experience. Instead, have your server return groups in the desired order.
const options = [
{ id: 1, label: "Item A", group: 3 },
{ id: 2, label: "Item B", group: 1 },
{ id: 3, label: "Item C", group: 2 },
];
// With SelectFieldSync
<SelectFieldSync
label="Items"
options={options}
groupToString={(group) => `Priority ${group}`}
groupSorter={(a, b) => Number(a) - Number(b)}
value={selectedOption}
onSelectedOptionChange={setSelectedOption}
/>
// With async SelectField (non-lazy)
<SelectField
label="Items"
loadOptions={async () => options}
groupToString={(group) => `Priority ${group}`}
groupSorter={(a, b) => Number(a) - Number(b)}
value={selectedOption}
onSelectedOptionChange={setSelectedOption}
/>
Mixed Grouped and Ungrouped Options
Options without a group property appear after all grouped sections:const options = [
{ id: 1, label: "Apple", group: "fruits" },
{ id: 2, label: "Banana", group: "fruits" },
{ id: 3, label: "Other Item" }, // No group - appears last
{ id: 4, label: "Carrot", group: "vegetables" },
];
SelectField Props
import { useState } from "react";
import { SelectField } from "@servicetitan/anvil2/beta";
const ExampleComponent = () => {
const [selectedOption, setSelectedOption] = useState(null);
return (
<SelectField
label="Search users"
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,
}));
}}
onSelectedOptionChange={setSelectedOption}
value={selectedOption}
/>
);
};
The label of the select field.
Function to load options. The signature depends on the lazy mode:
- Non-lazy:
(searchValue: string) => SelectFieldOption[] | Promise<SelectFieldOption[]>
- Page-based:
(searchValue: string, pageNumber: number, pageSize: number) => { options: SelectFieldOption[], hasMore?: boolean }
- Offset-based:
(searchValue: string, offset: number, limit: number) => { options: SelectFieldOption[], hasMore?: boolean }
- Group-based:
(searchValue: string, previousGroupKey: string | number | null) => { options: SelectFieldGroupedOption[], hasMore?: boolean }
onSelectedOptionChange
(option: SelectFieldOption | null) => void
required
Callback fired when the selected option changes.
value
SelectFieldOption | null
required
The currently selected option. Must be controlled state.
Configuration for caching loadOptions results:
enabled — Whether caching is enabled (default: true)
maxSize — Maximum number of search values to cache before clearing (default: 15)
Custom CSS class name for the wrapper element.
Milliseconds to debounce search input before calling loadOptions.
Description text displayed below the input field.
Whether to hide the clear button.
Whether the field is disabled. When disabled, the field cannot be interacted with and the dropdown cannot open.
How to display the options menu:
auto — Popover on desktop, dialog on mobile
popover — Always display as popover
dialog — Always display as dialog
error
ReactElement | string | boolean
Error state for the field. When true, shows error styling. When a string or ReactElement, also displays as an error message below the field.
errorAriaLive
'off' | 'assertive' | 'polite'
default:"assertive"
Controls the aria-live behavior for error messages. See MDN docs. groupSorter
(a: SelectFieldGroupByValue, b: SelectFieldGroupByValue) => number
Function to compare two group values for sorting. When provided, groups are sorted using this comparator. Without this, groups appear in the order they are first encountered. Best used with SelectFieldSync or non-lazy SelectField. Avoid using with lazy="group" as it causes the menu to shift unexpectedly when new groups load.
groupToString
(groupValue: SelectFieldGroupByValue) => string
Function to convert group values to display labels. Only used with grouped options. SelectFieldGroupByValue is string | number.
Visually hides the label above the input while keeping it accessible to screen readers. Note: This does not affect the label displayed in the adaptive dialog view on mobile devices.
Hint text displayed below the input field.
The id of the select field.
initialLoad
'auto' | 'immediate' | 'open'
default:"auto"
Controls when loadOptions is first called:
auto — Currently equivalent to immediate
immediate — Load on component mount
open — Load when dropdown is opened
Custom ReactNode to render as the label above the input, overriding the default label text. The label prop is still required for accessibility purposes. Note: This does not affect the label displayed in the adaptive dialog view on mobile devices.
lazy
'page' | 'offset' | 'group' | false
default:"false"
Enables lazy loading with the specified pagination strategy.
Configuration for lazy loading:
- Page mode:
{ pageSize?: number } (default pageSize: 20)
- Offset mode:
{ limit?: number } (default limit: 20)
- Group mode:
{}
Options to pin to the top of the list. Accepts a single section object or an array of section objects. Each section requires:
label — Display label for the section header
options — Static array (SelectFieldOption[]) or dynamic loader function ((searchValue: string) => SelectFieldOption[] | Promise<SelectFieldOption[]>)
searchReactive — (Optional) Whether to re-call the loader when the search value changes. Defaults to true. Set to false to call the loader once and reuse the result.
cacheSize — (Optional) Maximum number of search results to cache per section. Defaults to 15. Only applies when searchReactive is true.
Placeholder text for the input field.
Content to display before the input field.
Whether the field is read-only. When read-only, the dropdown can open to view options but selections cannot be changed.
Whether the field is required. Shows a red asterisk (*) next to the label.
size
'small' | 'medium' | 'large'
The size of the select field.
Custom inline styles for the wrapper element.
Content to display after the input field.
SelectFieldSync Props
SelectFieldSync accepts all props from SelectField except loadOptions, lazy, and debounceMs, plus the following: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"
onSelectedOptionChange={setSelectedOption}
options={options}
value={selectedOption}
/>
);
};
The label of the select field.
onSelectedOptionChange
(option: SelectFieldOption | null) => void
required
Callback fired when the selected option changes.
options
SelectFieldOption[]
required
The array of options to display in the select field.
value
SelectFieldOption | null
required
The currently selected option. Must be controlled state.
Custom CSS class name for the wrapper element.
Description text displayed below the input field.
Whether to hide the clear button.
Whether the field is disabled. When disabled, the field cannot be interacted with and the dropdown cannot open.
How to display the options menu.
error
ReactElement | string | boolean
Error state for the field. When true, shows error styling. When a string or ReactElement, also displays as an error message below the field.
errorAriaLive
'off' | 'assertive' | 'polite'
default:"assertive"
Controls the aria-live behavior for error messages.
filter
function | MatchSorterOptions
Custom filter configuration. Can be:
- A function:
(options: SelectFieldOption[], searchValue: string) => SelectFieldOption[]
- A match-sorter options object to customize the default filtering
Default: Filters by label and searchText fields using match-sorter. groupSorter
(a: SelectFieldGroupByValue, b: SelectFieldGroupByValue) => number
Function to compare two group values for sorting. When provided, options are sorted by group using this comparator, then by match-sort order within each group. Ungrouped options appear last.
groupToString
(groupValue: SelectFieldGroupByValue) => string
Function to convert group values to display labels. Only used with grouped options. SelectFieldGroupByValue is string | number.
Visually hides the label above the input while keeping it accessible to screen readers. Note: This does not affect the label displayed in the adaptive dialog view on mobile devices.
Hint text displayed below the input field.
The id of the select field.
Custom ReactNode to render as the label above the input, overriding the default label text. The label prop is still required for accessibility purposes. Note: This does not affect the label displayed in the adaptive dialog view on mobile devices.
Options to pin to the top of the list. Accepts the same section object format as SelectField’s pinned prop.
Placeholder text for the input field.
Content to display before the input field.
Whether the field is read-only. When read-only, the dropdown can open to view options but selections cannot be changed.
Whether the field is required. Shows a red asterisk (*) next to the label.
size
'small' | 'medium' | 'large'
The size of the select field.
Custom inline styles for the wrapper element.
Content to display after the input field.