Option Resolvers
Load select field options dynamically from async sources with automatic dependency tracking and cascading dropdown support.
Option Resolvers
Option resolvers let you load options for select, radio, and checkbox fields from async sources — REST APIs, GraphQL, databases, or any async function. The system automatically tracks field dependencies, deduplicates concurrent requests, and clears stale values in cascading dropdowns.
Quick Start
1. Define Option Resolvers
Use defineOptionResolvers to create a registry of async option fetchers:
import { defineOptionResolvers } from "@buildnbuzz/form-react";
const apiResolvers = defineOptionResolvers({
listCountries: async () => {
const res = await fetch("https://countriesnow.space/api/v0.1/countries");
const json = await res.json();
return json.data.map((c: { country: string }) => ({
label: c.country,
value: c.country,
}));
},
listStates: async ({ data }) => {
const country = data.country as string;
if (!country) return [];
const res = await fetch(
`https://countriesnow.space/api/v0.1/countries/states/q?country=${encodeURIComponent(country)}`,
);
const json = await res.json();
return json.data.states.map((s: { name: string }) => ({
label: s.name,
value: s.name,
}));
},
});2. Reference Resolvers in Your Schema
Use { resolver: "key" } to reference a resolver by name:
const locationSchema = defineSchema({
fields: [
{
type: "select",
name: "country",
label: "Country",
options: { resolver: "listCountries" },
},
{
type: "select",
name: "state",
label: "State",
options: { resolver: "listStates" },
dependencies: ["/country"],
},
],
});3. Pass Resolvers to the Form
<Form
schema={locationSchema}
optionResolvers={apiResolvers}
onSubmit={({ value }) => console.log(value)}
>
<FormContent>
<FormFields />
<FormSubmit>Submit</FormSubmit>
</FormContent>
</Form>Resolver Function Signature
Each resolver receives a context object:
interface OptionResolverContext {
/** Current form values */
data: Record<string, unknown>;
/** External context passed via contextData */
context: Record<string, unknown> | undefined;
}
type OptionResolverFn = (
ctx: OptionResolverContext,
args?: Record<string, unknown>,
) => Promise<Array<{ label: string; value: string; disabled?: boolean }>>;Using data (Current Form Values)
Access any field's current value from the data object. This is how cascading dropdowns work — a child resolver reads its parent's value:
listCities: async ({ data }) => {
const country = data.country as string;
const state = data.state as string;
if (!country || !state) return [];
const res = await fetch(`/api/cities?country=${country}&state=${state}`);
const json = await res.json();
return json.cities.map((city: string) => ({ label: city, value: city }));
},Using context (External Data)
Access external data passed via contextData on useForm or <Form>:
const form = useForm({
schema: mySchema,
contextData: { tenantId: "acme", apiUrl: "https://api.example.com" },
});
// In resolver:
listProjects: async ({ context }) => {
const tenantId = context?.tenantId as string;
const res = await fetch(`${context?.apiUrl}/tenants/${tenantId}/projects`);
// ...
},Using args (Static Configuration)
Pass static configuration to a resolver via the args property in the schema:
// Schema
{
type: "select",
name: "category",
options: { resolver: "listItems", args: { limit: 50, sort: "name" } },
}
// Resolver
listItems: async ({ data }, args) => {
const limit = args?.limit as number ?? 20;
const sort = args?.sort as string ?? "id";
const res = await fetch(`/api/items?limit=${limit}&sort=${sort}`);
// ...
},Dependencies
Declare which fields trigger a re-fetch using the dependencies array:
{
type: "select",
name: "city",
label: "City",
options: { resolver: "listCities" },
dependencies: ["/country", "/state"], // re-fetch when either changes
}When a dependency changes, the system:
- Clears the field value — prevents stale selections from invalid options
- Re-fetches options — calls the resolver with the latest form values
- Updates loading state —
isLoadingbecomestrueduring fetch
Inline Async Option Fetchers
For simple cases, you can pass an async function directly as options:
const schema = defineSchema({
fields: [
{
type: "select",
name: "tags",
label: "Tags",
options: async ({ data }) => {
const category = data.category as string;
const res = await fetch(`/api/tags?category=${category}`);
const json = await res.json();
return json.tags.map((tag: string) => ({ label: tag, value: tag }));
},
dependencies: ["/category"],
},
],
});Note: Inline async functions make the schema non-JSON-serializable. Use the resolver registry ({ resolver: "key" }) if you need to serialize the schema (e.g., for a form builder).
Request Deduplication
Concurrent calls to the same resolver with identical parameters are automatically deduplicated. If two fields resolve options at the same time with the same config, only one API request is made.
Disabled Options
Options can be statically or dynamically disabled:
// Static disabled
{ label: "Legacy", value: "legacy", disabled: true }
// Dynamic — disabled when /country is not "US"
{ label: "Texas", value: "TX", disabled: { $data: "/country", neq: "US" } }When disabled is not specified, it defaults to false (all options enabled).
Cascading Dropdown Pattern
The most common use case is cascading dropdowns — where each field's options depend on the previous field's selection:
const resolvers = defineOptionResolvers({
countries: async () => { /* ... */ },
states: async ({ data }) => { /* uses data.country */ },
cities: async ({ data }) => { /* uses data.country + data.state */ },
});
const schema = defineSchema({
fields: [
{
type: "select",
name: "country",
label: "Country",
options: { resolver: "countries" },
},
{
type: "select",
name: "state",
label: "State",
options: { resolver: "states" },
dependencies: ["/country"],
disabled: { $data: "/country", not: true },
},
{
type: "select",
name: "city",
label: "City",
options: { resolver: "cities" },
dependencies: ["/country", "/state"],
disabled: { $data: "/state", not: true },
},
],
});Key patterns in this example:
dependencies— tells the system when to re-fetch optionsdisabled: { $data: "/parent", not: true }— disables the field until the parent is selected- Automatic value clearing — changing country clears state; changing state clears city
Loading States
The useFieldOptions hook returns an isLoading flag you can use in custom field components:
const { options, isLoading, error } = useFieldOptions(field.options);
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage>{error.message}</ErrorMessage>;
return <Select options={options} />;The built-in SelectField automatically shows a loading indicator and disables interaction while options are loading.
Error Handling
When a resolver throws an error:
- The error is caught and surfaced via the hook's
errorproperty - The field value is cleared (since options failed to load)
- A toast notification can be shown by the resolver itself
listStates: async ({ data }) => {
try {
const res = await fetch(`/api/states?country=${data.country}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
return json.states.map((s: State) => ({ label: s.name, value: s.code }));
} catch {
toast.error("Could not fetch states. Check your network connection.");
return [];
}
},Related
- Select Field — Select field properties and UI options
- Dynamic Values — Using
$dataand$contextin other properties - Conditional Logic — Show/hide fields based on conditions