Dependent Dropdowns Without the useEffect Mess
Every app has at least one: a select that loads its options based on what you picked in another select. Country → state → city. Category → subcategory. Brand → model.
The naive implementation works until it doesn't.
What it usually looks like (before)
Three selects, three loading states, two useEffects, and a lot of cleanup logic.
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
export function LocationForm() {
const { register, watch, handleSubmit } = useForm();
const [states, setStates] = useState([]);
const [cities, setCities] = useState([]);
const [loadingStates, setLoadingStates] = useState(false);
const [loadingCities, setLoadingCities] = useState(false);
const selectedCountry = watch("country");
const selectedState = watch("state");
useEffect(() => {
if (!selectedCountry) return;
setStates([]); // clear stale options
setCities([]); // also clear grandchild
setLoadingStates(true);
fetch(`/api/states?country_code=${selectedCountry}`)
.then((r) => r.json())
.then(setStates)
.finally(() => setLoadingStates(false));
}, [selectedCountry]);
useEffect(() => {
if (!selectedState) return;
setCities([]);
setLoadingCities(true);
fetch(`/api/cities?state_code=${selectedState}`)
.then((r) => r.json())
.then(setCities)
.finally(() => setLoadingCities(false));
}, [selectedState]);
return (
<form onSubmit={handleSubmit(console.log)}>
<select {...register("country")}>
<option value="US">United States</option>
<option value="CA">Canada</option>
</select>
<select {...register("state")} disabled={loadingStates || !selectedCountry}>
{loadingStates
? <option>Loading...</option>
: states.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)
}
</select>
<select {...register("city")} disabled={loadingCities || !selectedState}>
{loadingCities
? <option>Loading...</option>
: cities.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)
}
</select>
<button type="submit">Continue</button>
</form>
);
}
This works, but it has real problems.
State management sprawls. Two loading booleans, two option arrays, manual clearing on parent change. Add a fourth level and you're writing three useEffects.
Stale values silently submit. Change country from US to Canada — state was "California". The state field still shows "CA" as the value even though it's no longer valid. Nothing clears it automatically.
No standard pattern. The next dev who adds a cascading select will do it differently, and now you have two patterns to maintain.
With Formwright (after)
Declare the dependency in the schema. The engine re-fetches when the parent changes.
import { buildForm, defineForm, field, layout, datasource, rule, fieldRef } from "formwright/schema";
const country = field.select("country", {
label: "Country",
dataSource: "countries",
});
const state = field.select("state", {
label: "State / Province",
dataSource: "states",
});
const city = field.select("city", {
label: "City",
dataSource: "cities",
});
export const locationForm = buildForm({
form: defineForm({ id: "location" }),
fields: [country, state, city],
layout: layout.stack("root", [
layout.field(country),
layout.field(state),
layout.field(city),
]),
datasources: [
datasource.static("countries", [
{ label: "United States", value: "US" },
{ label: "Canada", value: "CA" },
{ label: "United Kingdom", value: "GB" },
]),
// `dependsOn` tells the engine to re-fetch when `country` changes.
// `queryMap` maps current field values to query params using {{fieldPath}} syntax.
datasource.remote("states", {
endpoint: "/api/states",
method: "GET",
dependsOn: ["country"],
queryMap: { country_code: "{{country}}" },
}),
datasource.remote("cities", {
endpoint: "/api/cities",
method: "GET",
dependsOn: ["state"],
queryMap: { state_code: "{{state}}" },
}),
],
rules: [
// Hide child selects until parent has a value
rule.when(fieldRef(country).exists()).show(state),
rule.when(fieldRef(state).exists()).show(city),
rule.when(fieldRef(country).exists()).clearValue(state),
rule.when(fieldRef(state).exists()).clearValue(city),
],
});
import { FormRuntimeProvider, FormRuntimeRoot, useCreateFormRuntime } from "formwright/react";
import { registerBasicPlugins } from "formwright/plugins";
import { registerAsyncPlugins } from "formwright/plugins-async";
import { locationForm } from "./location-form";
export function LocationPage() {
const runtime = useCreateFormRuntime({
form: locationForm,
plugins: [...registerBasicPlugins(), ...registerAsyncPlugins()],
});
return (
<FormRuntimeProvider runtime={runtime} hiddenFieldPolicy="clear">
<FormRuntimeRoot rootLayoutId="root" />
</FormRuntimeProvider>
);
}
No useState. No useEffect. No manual clearing. The datasource plugin handles loading state, and the rules handle visibility and value clearing.
What the API expects
The remote endpoint returns a JSON array:
GET /api/states?country_code=US
[
{ "label": "California", "value": "CA" },
{ "label": "New York", "value": "NY" },
{ "label": "Texas", "value": "TX" }
]
If your API returns a different shape, map it with labelKey and valueKey:
datasource.remote("states", {
endpoint: "/api/states",
dependsOn: ["country"],
queryMap: { country_code: "{{country}}" },
labelKey: "stateName",
valueKey: "stateCode",
})
If your API expects POST instead of GET:
datasource.remote("cities", {
endpoint: "/api/cities/search",
method: "POST",
dependsOn: ["state"],
bodyMap: { stateCode: "{{state}}" },
})
What changes and what stays the same
What changes:
- Fetch + loading state moves from
useState/useEffectintodatasource.remotewithdependsOn - Stale child value clearing moves from manual
setState([])intorule.when(...).clearValue(...) - Dependency declaration is colocated with the field, not buried in a component
What stays the same:
- Your API endpoints don't change — only how the frontend calls them
- Options still arrive as
{ label, value }pairs - Loading state is still passed to your custom renderer if you use one
See also
- Data Sources — POST body, custom plugins,
useDatasourceOptionshook - Conditional Fields —
show,hide,clearValuerules