Overview
The tree select field family includes two components for different use cases:
TreeSelectField — For async data loading with support for lazy branch expansion
- Includes automatic debouncing of the search input (configurable via the
debounceMs prop)
- Includes automatic LRU caching of search results and loaded children (configurable via the
cache prop)
TreeSelectFieldSync — For client-side filtering of static tree option arrays
Both components provide a hierarchical tree dropdown with cascading parent/child selection, keyboard navigation, and adaptive display modes (popover or dialog).TreeSelectFieldSync (Static Options)
Use TreeSelectFieldSync when you have a static tree of options that can be filtered client-side.import { useState } from "react";
import { TreeSelectFieldSync, TreeSelectFieldValue } from "@servicetitan/anvil2/beta";
const options = [
{
id: "eng",
label: "Engineering",
children: [
{ id: "fe", label: "Frontend" },
{ id: "be", label: "Backend" },
{ id: "infra", label: "Infrastructure" },
],
},
{
id: "design",
label: "Design",
children: [
{ id: "ux", label: "UX Design" },
{ id: "visual", label: "Visual Design" },
],
},
{ id: "hr", label: "Human Resources" },
];
const ExampleComponent = () => {
const [selected, setSelected] = useState<TreeSelectFieldValue[]>([]);
return (
<TreeSelectFieldSync
label="Select departments"
options={options}
value={selected}
onSelectedOptionsChange={setSelected}
defaultExpandLevel={1}
/>
);
};
Filtering and Sorting
By default, TreeSelectFieldSync uses match-sorter to filter options by their label and searchText fields. The tree structure is preserved: parent nodes of any matching node remain visible so the hierarchy is clear.You can customize this behavior in two ways:Using match-sorter options
Pass a match-sorter options object to customize the default filtering and sorting behavior:<TreeSelectFieldSync
options={options}
filter={{
keys: ["label", "searchText"],
// ...other match-sorter options
}}
// ...other props
/>
Using a custom filter function
Pass a function for full control over both filtering and tree structure. The returned array determines the exact tree shown in the dropdown:<TreeSelectFieldSync
options={options}
filter={(nodes, searchValue) => {
// Return a filtered tree — parent preservation is your responsibility
return customTreeFilter(nodes, searchValue);
}}
// ...other props
/>
TreeSelectField (Async Loading)
Use TreeSelectField when tree data needs to be fetched from an API or when branches load their children on demand.Basic Async Loading
import { useState } from "react";
import { TreeSelectField, TreeSelectFieldValue } from "@servicetitan/anvil2/beta";
const ExampleComponent = () => {
const [selected, setSelected] = useState<TreeSelectFieldValue[]>([]);
return (
<TreeSelectField
label="Select categories"
loadOptions={async (searchValue) => {
const response = await fetch(`/api/categories?q=${searchValue}`);
return response.json();
}}
value={selected}
onSelectedOptionsChange={setSelected}
/>
);
};
Lazy Branch Loading
Branches with children: null are treated as unloaded. When expanded, loadOptions is called with the parent node to fetch its children:import { useState } from "react";
import {
TreeSelectField,
TreeSelectFieldNode,
TreeSelectFieldValue,
} from "@servicetitan/anvil2/beta";
const ExampleComponent = () => {
const [selected, setSelected] = useState<TreeSelectFieldValue[]>([]);
return (
<TreeSelectField
label="Select location"
loadOptions={async (searchValue, parentNode) => {
if (parentNode) {
// Load children for the expanded branch
const response = await fetch(`/api/locations/${parentNode.id}/children`);
return response.json();
}
// Load root nodes
const response = await fetch(`/api/locations?q=${searchValue}`);
return response.json();
}}
value={selected}
onSelectedOptionsChange={setSelected}
/>
);
};
Root nodes returned by loadOptions should use children: null for branches whose children haven’t been loaded yet:// Example response from /api/locations
[
{
id: "us",
label: "United States",
children: null, // Children will be loaded when this branch is expanded
},
{
id: "ca",
label: "Canada",
children: null,
},
]
When a user selects a branch with unloaded descendants in linked selection mode, all descendant branches are recursively loaded before the cascade is applied. A loading spinner appears on the checkbox during this process.Progressive (Multi-Level) Loading
Lazy loading is recursive: when the children returned for one branch are themselves
branches with children: null, each level loads on demand as the user drills in.
This is the common shape for a file browser or a deep location hierarchy where you
never want to fetch the whole tree up front.import { useState } from "react";
import {
TreeSelectField,
TreeSelectFieldNode,
TreeSelectFieldValue,
} from "@servicetitan/anvil2/beta";
const FilePicker = () => {
const [selected, setSelected] = useState<TreeSelectFieldValue[]>([]);
const loadOptions = async (
searchValue: string,
parentNode?: TreeSelectFieldNode,
): Promise<TreeSelectFieldNode[]> => {
// Expanding a folder: fetch only its immediate entries. Sub-folders come
// back with `children: null` so they load on their own expansion.
if (parentNode) {
const entries = await fetchFolder(parentNode.id);
return entries.map((entry) => ({
id: entry.id,
label: entry.name,
children: entry.isFolder ? null : undefined,
childCount: entry.isFolder ? entry.count : undefined,
}));
}
// Roots — or a server-side search across the whole tree.
if (searchValue) return searchAllFolders(searchValue);
return fetchFolder("root");
};
return (
<TreeSelectField
label="Attach a file"
loadOptions={loadOptions}
value={selected}
onSelectedOptionsChange={setSelected}
selectionMode="single"
// Don't fetch anything until the user opens the menu.
initialLoad="open"
// Keep recently-opened folders warm so re-expanding is instant.
cache={{ enabled: true, maxSize: 50 }}
/>
);
};
After a mutation that changes the underlying data (an upload, a rename, a delete),
call invalidate() on the ref to drop the cache and re-fetch from the top:const fieldRef = useRef<TreeSelectFieldHandle>(null);
const handleUploadComplete = () => {
fieldRef.current?.invalidate();
};
Restoring a Selection Under Unloaded Branches
With lazy loading, a selected node can sit under branches that haven’t been
fetched yet — most commonly when you restore a persisted selection on
mount, before the user has expanded anything. In linked mode the field shows
a parent as checked or indeterminate by looking at its children, but those
children aren’t loaded, so the ancestor checkboxes can’t reflect the
selection until the branch is expanded.Provide the selected value’s ancestry via the optional path field
(ancestor ids, root → parent) so the field can mark the correct ancestor
branches immediately, with no extra fetching:// A selection restored from storage. `path` lets the field show
// "Engineering" and "Frontend" as indeterminate before they're loaded.
const [selected, setSelected] = useState<TreeSelectFieldValue[]>([
{
id: "alice",
label: "Alice Chen",
path: ["engineering", "frontend"],
},
]);
You don’t have to build path by hand for selections made through the UI:
when the field emits a selection whose ancestors are loaded, it stamps
path onto the value for you. Persisting value as-is and restoring it
later round-trips the ancestry automatically.Without path, the field still resolves ancestry for any branch the user has
expanded this session (it remembers child→parent relationships as branches
load). A selection whose ancestors have never been loaded simply shows its
branches unchecked until they’re expanded — at which point the state
self-corrects. Supplying path is what makes a cold restore correct up front.During search, set childCount on branch nodes so the field can tell a
partially-matched branch from a fully-selected one — see
Search and childCount. Selection Modes
The selectionMode prop controls how nodes relate to each other during selection:
"linked" (default) — Parent-child cascading. Selecting a parent checks all children; selecting all children checks the parent. The valueConsistsOf prop controls which nodes appear in the value array.
"independent" — Each node is selected independently with no cascading.
"single" — Only one node can be selected at a time. The menu closes after selection.
// Linked mode (default) — cascading selection
<TreeSelectFieldSync
selectionMode="linked"
options={options}
// ...other props
/>
// Independent mode — no cascading
<TreeSelectFieldSync
selectionMode="independent"
options={options}
// ...other props
/>
// Single-select mode — one node at a time
<TreeSelectFieldSync
selectionMode="single"
options={options}
// ...other props
/>
Value Strategies
In linked selection mode, the valueConsistsOf prop controls which checked nodes appear in the value array:| Strategy | Selectable Nodes | Value Contains |
|---|
"ALL" | Any node | All checked nodes |
"BRANCH_PRIORITY" | Any node | Branches replace their leaves when fully checked |
"BRANCH_ONLY" | Branches only | Only branches (leaves are visible but inert) |
"LEAF_PRIORITY" (default) | Leaves + expand branches | Leaves and childless branches |
"LEAF_ONLY" | Leaves only | Strictly leaf nodes |
<TreeSelectFieldSync
selectionMode="linked"
valueConsistsOf="BRANCH_PRIORITY"
options={options}
// ...other props
/>
In single-select mode, valueConsistsOf restricts which node types can be selected: "LEAF_ONLY" allows only leaves, "BRANCH_ONLY" allows only branches, and the other strategies allow any node.What ends up in value
The same user action produces a different value array depending on the
strategy. Given this tree, with the user checking Building B (which checks
its only floor and both of that floor’s rooms):Headquarters
└─ Building B ← checked
└─ 3rd Floor
├─ Conference Room
└─ Focus Room
valueConsistsOf | Resulting value (by label) |
|---|
"ALL" | Building B, 3rd Floor, Conference Room, Focus Room |
"BRANCH_PRIORITY" | Building B (the branch replaces its fully-checked subtree) |
"BRANCH_ONLY" | Building B, 3rd Floor (only branches; rooms are inert) |
"LEAF_PRIORITY" | Conference Room, Focus Room (leaves only) |
"LEAF_ONLY" | Conference Room, Focus Room (leaves only) |
BRANCH_PRIORITY is the right choice when “select the whole branch” should
read as a single chip; LEAF_ONLY is right when the consumer only ever wants
concrete items (e.g. people, not teams) in the submitted value.Display Modes
Control how the options menu is displayed using the displayMenuAs prop:// Automatically choose based on device (default)
<TreeSelectFieldSync displayMenuAs="auto" {...props} />
// Always show as popover (not recommended for mobile)
<TreeSelectFieldSync displayMenuAs="popover" {...props} />
// Always show as dialog
<TreeSelectFieldSync displayMenuAs="dialog" {...props} />
Caching
TreeSelectField caches loadOptions results by default using two separate LRU caches: one for search results and one for lazy-loaded children. Configure caching behavior:// Disable caching
<TreeSelectField cache={{ enabled: false }} {...props} />
// Configure max cache size (default: 15)
<TreeSelectField cache={{ maxSize: 100 }} {...props} />
Clearing the Cache
Use a ref to imperatively clear the cache:import { useRef } from "react";
import { TreeSelectField, TreeSelectFieldHandle } from "@servicetitan/anvil2/beta";
const ExampleComponent = () => {
const fieldRef = useRef<TreeSelectFieldHandle>(null);
const handleRefresh = () => {
fieldRef.current?.clearCache();
};
return (
<>
<TreeSelectField ref={fieldRef} {...props} />
<button onClick={handleRefresh}>Refresh Options</button>
</>
);
};
Invalidating Options
Call invalidate() to clear the cache and reload options from the data source:const handleDataSourceChange = () => {
fieldRef.current?.invalidate();
};
TreeSelectFieldSync handles this automatically when its options prop reference changes.
Initial Load Behavior
Control when options are first loaded with the initialLoad prop:// Load immediately on mount (default)
<TreeSelectField initialLoad="immediate" {...props} />
// Load when user opens the dropdown
<TreeSelectField initialLoad="open" {...props} />
// Auto (currently equivalent to "immediate")
<TreeSelectField initialLoad="auto" {...props} />
Controlled Expansion
By default, expansion state is managed internally. Use defaultExpandLevel to set the initial depth:// Start with all branches collapsed (default)
<TreeSelectFieldSync defaultExpandLevel={0} options={options} {...props} />
// Start with first level expanded
<TreeSelectFieldSync defaultExpandLevel={1} options={options} {...props} />
// Start fully expanded
<TreeSelectFieldSync defaultExpandLevel={Infinity} options={options} {...props} />
For full control, use expandedIds and onExpandedIdsChange. Because the set
lives in your state, external UI can drive expansion directly — for example, a
button that drills the tree open to a specific path:const [expandedIds, setExpandedIds] = useState<Set<string | number>>(
() => new Set(["hq", "bldg-b", "b-3"]),
);
<TreeSelectFieldSync
expandedIds={expandedIds}
onExpandedIdsChange={setExpandedIds}
options={options}
// ...other props
/>
// Jump straight to Building B › 3rd Floor
<button onClick={() => setExpandedIds(new Set(["hq", "bldg-b", "b-3"]))}>
Go to 3rd Floor
</button>
// Collapse everything
<button onClick={() => setExpandedIds(new Set())}>Collapse all</button>
The imperative handle also provides expandAll() and collapseAll() for when
you don’t need to track the exact set:fieldRef.current?.expandAll();
fieldRef.current?.collapseAll();
Controlled Search
By default the search input is uncontrolled. Provide searchValue and
onSearchChange to keep it in sync with your own state — useful when an
external search box should drive the field, or when you filter loadOptions
server-side and want to react to the query yourself.const [search, setSearch] = useState("");
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search people…"
/>
<TreeSelectFieldSync
options={options}
value={selected}
onSelectedOptionsChange={setSelected}
searchValue={search}
onSearchChange={setSearch}
// ...other props
/>
Typing inside the field calls onSearchChange, and updating searchValue
externally re-filters the tree — the two stay in lockstep.Search and childCount
When a search filter hides some of a branch’s children, the field only sees
the matches that remain. To avoid showing a branch as fully checked when an
unselected child is merely filtered out of view, set childCount (the
branch’s true number of children) on branch nodes. The field compares the
loaded children against childCount: if fewer are present than the real
total, a branch with some — but not provably all — children selected renders
as indeterminate rather than checked.// childCount reflects the full child set, independent of the current filter.
{
id: "sales-marketing",
label: "Sales & Marketing",
childCount: 2, // even when only "Sales" matches the search
children: null,
}
TreeSelectFieldSync derives childCount automatically from its static
options, so client-side filtering is always accurate. For async
TreeSelectField, return childCount from loadOptions (it should reflect
the unfiltered count). Without it, a partially-filtered branch falls back to
reflecting only its visible children.Field States
Error State
<TreeSelectFieldSync
label="Department"
options={options}
value={selected}
onSelectedOptionsChange={setSelected}
error="Please select at least one department"
/>
Hint and Description
<TreeSelectFieldSync
label="Department"
options={options}
value={selected}
onSelectedOptionsChange={setSelected}
hint="Select the departments this applies to"
description="Used for routing and permissions"
/>
Required Field
<TreeSelectFieldSync
label="Department"
options={options}
value={selected}
onSelectedOptionsChange={setSelected}
required
/>
Disabled and ReadOnly
When disabled is set, users cannot interact with the field.<TreeSelectFieldSync
label="Department"
options={options}
value={selected}
onSelectedOptionsChange={setSelected}
disabled
/>
When readOnly is set, users can browse the tree but cannot change the selection.<TreeSelectFieldSync
label="Department"
options={options}
value={selected}
onSelectedOptionsChange={setSelected}
readOnly
/>
Prefix and Suffix
<TreeSelectFieldSync
label="Region"
options={regions}
value={selected}
onSelectedOptionsChange={setSelected}
prefix="Location:"
/>
Markdown in labels
The label prop supports inline markdown: bold (**text**), italic (*text*), highlight (==text==), and code (`text`).Hide the label
Use hideLabel to visually hide the label. The label string remains accessible to screen readers.<TreeSelectFieldSync
label="Department"
hideLabel
placeholder="Select departments..."
options={options}
value={selected}
onSelectedOptionsChange={setSelected}
/>
Disabled Nodes
Individual nodes can be disabled by setting disabled: true:const options = [
{
id: "eng",
label: "Engineering",
children: [
{ id: "fe", label: "Frontend" },
{ id: "be", label: "Backend", disabled: true },
],
},
];
Disabled nodes appear with reduced opacity and cannot be selected. In linked mode, disabled nodes are excluded from cascade selection.Virtualization
Pass virtualize to enable windowed rendering for large trees. Only visible rows are mounted to the DOM.<TreeSelectField
virtualize
label="Large tree"
loadOptions={loadLargeTree}
value={selected}
onSelectedOptionsChange={setSelected}
/>
Disabling Search
Pass disableSearch to hide the search input:<TreeSelectFieldSync
disableSearch
label="Category"
options={options}
value={selected}
onSelectedOptionsChange={setSelected}
/>
Chip Display
In multi-select mode, selected values appear as removable chips. Control chip layout with singleRow and maxChips:// Single row with overflow count
<TreeSelectFieldSync
singleRow
options={options}
// ...other props
/>
// Limit visible chips
<TreeSelectFieldSync
maxChips={3}
options={options}
// ...other props
/>
TreeSelectField Props
import { useState } from "react";
import { TreeSelectField, TreeSelectFieldValue } from "@servicetitan/anvil2/beta";
const ExampleComponent = () => {
const [selected, setSelected] = useState<TreeSelectFieldValue[]>([]);
return (
<TreeSelectField
label="Select categories"
loadOptions={async (searchValue, parentNode) => {
if (parentNode) {
return fetchChildren(parentNode.id);
}
return fetchRoots(searchValue);
}}
onSelectedOptionsChange={setSelected}
value={selected}
/>
);
};
The label of the tree select field. Supports inline markdown formatting.
loadOptions
(searchValue: string, parentNode?: TreeSelectFieldNode) => TreeSelectFieldNode[] | Promise<TreeSelectFieldNode[]>
required
Function to load tree nodes. Called with an empty string on initial load and with the search value when the user types. When a branch with children: null is expanded, called again with the parent node as the second argument to load its children.
onSelectedOptionsChange
(nodes: TreeSelectFieldValue[]) => void
required
Callback fired when the selection changes. Receives the new array of selected values.
value
TreeSelectFieldValue[]
required
The currently selected values. Always an array, even in single-select mode.
cache
{ enabled?: boolean; maxSize?: number }
Configuration for caching loadOptions results. Two separate LRU caches are maintained: one for search results and one for lazy-loaded children.
enabled — Whether caching is enabled (default: true)
maxSize — Maximum entries per cache before eviction (default: 15)
Custom CSS class name for the wrapper element.
Milliseconds to debounce search input before calling loadOptions.
Initial expansion depth. 0 keeps all branches collapsed, 1 expands the first level, Infinity expands everything. Applied when tree data first loads.
Description text displayed below the input field.
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
boolean | string | string[] | ReactElement
Error state for the field. When true, shows error styling. When a string, string array, or ReactElement, also displays as an error message below the field.
Controlled set of expanded branch node IDs. Use with onExpandedIdsChange for full control over expansion state.
Visually hides the label while keeping it accessible to screen readers.
Hint text displayed below the input field.
The id of the tree select field.
initialLoad
"auto" | "immediate" | "open"
Controls when loadOptions is first called:
auto — Currently equivalent to immediate
immediate — Load on component mount
open — Load when dropdown is opened
Custom label content. Overrides the label string for rendering but label is still used for accessibility.
Maximum number of chips to display in multi-select mode before showing an overflow count.
onExpandedIdsChange
(ids: Set<string | number>) => void
Callback fired when expansion state changes. Use with expandedIds for controlled expansion.
Callback fired when the search input value changes. Use with searchValue for controlled search.
Placeholder text for the search input.
Content to display before the input field.
Whether the field is read-only. When read-only, the dropdown can open to browse the tree but selections cannot be changed.
Whether the field is required. Shows a red asterisk (*) next to the label.
Controlled search input value. Use with onSearchChange for controlled search.
selectionMode
"single" | "independent" | "linked"
default:"linked"
Controls how nodes relate to each other during selection:
single — One node at a time, menu closes after selection
independent — No parent-child cascading
linked — Parent-child cascading with configurable value shape via valueConsistsOf
When true, selected chips display in a single row with horizontal overflow.
size
"small" | "medium" | "large"
The size of the tree select field.
Custom inline styles for the wrapper element.
Content to display after the input field.
valueConsistsOf
"ALL" | "BRANCH_PRIORITY" | "BRANCH_ONLY" | "LEAF_PRIORITY" | "LEAF_ONLY"
default:"LEAF_PRIORITY"
Controls which nodes are selectable and how the value array is shaped in linked selection mode:
ALL — Any node selectable, all checked nodes in value
BRANCH_PRIORITY — Any node selectable, branches replace leaves when fully checked
BRANCH_ONLY — Only branches selectable, leaves visible but inert
LEAF_PRIORITY — Leaves and childless branches in value
LEAF_ONLY — Strictly leaf nodes in value
Enables windowed rendering for the tree dropdown. Only visible rows are mounted to the DOM.
Warning message(s) displayed below the input field.
TreeSelectFieldSync Props
TreeSelectFieldSync accepts all props from TreeSelectField except loadOptions, debounceMs, cache, and initialLoad, plus the following:import { useState } from "react";
import { TreeSelectFieldSync, TreeSelectFieldValue } from "@servicetitan/anvil2/beta";
const options = [
{
id: "eng",
label: "Engineering",
children: [
{ id: "fe", label: "Frontend" },
{ id: "be", label: "Backend" },
],
},
];
const ExampleComponent = () => {
const [selected, setSelected] = useState<TreeSelectFieldValue[]>([]);
return (
<TreeSelectFieldSync
label="Select departments"
onSelectedOptionsChange={setSelected}
options={options}
value={selected}
/>
);
};
The label of the tree select field. Supports inline markdown formatting.
onSelectedOptionsChange
(nodes: TreeSelectFieldValue[]) => void
required
Callback fired when the selection changes.
options
TreeSelectFieldNode[]
required
The static tree of options to display.
value
TreeSelectFieldValue[]
required
The currently selected values.
Custom CSS class name for the wrapper element.
Initial expansion depth. 0 keeps all branches collapsed, 1 expands the first level, Infinity expands everything.
Description text displayed below the input field.
Whether the field is disabled.
How to display the options menu.
error
boolean | string | string[] | ReactElement
Error state for the field.
Controlled set of expanded branch node IDs.
filter
function | MatchSorterOptions
Controls how options are filtered when the user types a search value. Can be:
- A function:
(nodes: TreeSelectFieldNode[], searchValue: string) => TreeSelectFieldNode[] — receives all options and returns the filtered tree
- A match-sorter options object to customize the default match-sorter behavior
Default: Filters by label and searchText using match-sorter, preserving parent nodes of any match. Visually hides the label while keeping it accessible to screen readers.
Hint text displayed below the input field.
The id of the tree select field.
Maximum number of chips to display before showing an overflow count.
onExpandedIdsChange
(ids: Set<string | number>) => void
Callback fired when expansion state changes.
Callback fired when the search input value changes.
Placeholder text for the search input.
Content to display before the input field.
Whether the field is read-only.
Whether the field is required.
Controlled search input value.
selectionMode
"single" | "independent" | "linked"
default:"linked"
Controls how nodes relate to each other during selection.
When true, selected chips display in a single row.
size
"small" | "medium" | "large"
The size of the tree select field.
Custom inline styles for the wrapper element.
Content to display after the input field.
valueConsistsOf
"ALL" | "BRANCH_PRIORITY" | "BRANCH_ONLY" | "LEAF_PRIORITY" | "LEAF_ONLY"
default:"LEAF_PRIORITY"
Controls which nodes are selectable and how the value array is shaped.
Enables windowed rendering for the tree dropdown.
Warning message(s) displayed below the input field.