Skip to main content

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.

LocationForm.tsx
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.

location-form.ts
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),
],
});
LocationPage.tsx
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/useEffect into datasource.remote with dependsOn
  • Stale child value clearing moves from manual setState([]) into rule.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