Validating Dependent Dropdowns Where One Selection Constrains Another
When a country <select> decides which region values are legal, or a plan tier decides which seat counts are allowed, the child dropdown’s valid set changes at runtime and a stale or impossible pairing must be rejected before submit; this recipe wires that dependency through cross-field validation so the parent selection actively constrains the child.
When to Use This setCustomValidity Constraint Approach
Reach for this pattern when the set of acceptable child values is a pure function of the parent value and you can enumerate it on the client. It differs from neighbouring recipes on the site:
- Use this page when one field’s value narrows another field’s allowed options, not its format.
- Use Cross-Field Password Confirmation Logic when two fields must be equal.
- Use Validating a Date Range (Start Before End) when the relationship is a numeric ordering.
- If the child’s legal set lives only on the server, fetch it through asynchronous server checks instead of enumerating locally.
Minimal setCustomValidity Implementation on a novalidate Form
The baseline is a <form novalidate> whose validity is driven through a single manual reportValidity() call against the Constraint Validation API. The parent’s change listener rebuilds the child options and stamps a custom validity string whenever the current child value is no longer legal.
<form id="location" novalidate>
<label for="country">Country</label>
<select id="country" name="country" required>
<option value="">Choose…</option>
<option value="us">United States</option>
<option value="ca">Canada</option>
</select>
<label for="region">Region</label>
<select id="region" name="region" required></select>
<button type="submit">Continue</button>
</form>
// The single source of truth: parent value -> legal child values.
const REGIONS: Record<string, { value: string; label: string }[]> = {
us: [
{ value: "ca", label: "California" },
{ value: "ny", label: "New York" },
],
ca: [
{ value: "on", label: "Ontario" },
{ value: "qc", label: "Quebec" },
],
};
const form = document.querySelector<HTMLFormElement>("#location")!;
const country = form.elements.namedItem("country") as HTMLSelectElement;
const region = form.elements.namedItem("region") as HTMLSelectElement;
// Rebuild the child <option>s for the parent's current value,
// preserving the prior child selection only when it stays legal.
function repopulateRegion(): void {
const options = REGIONS[country.value] ?? [];
const previous = region.value;
region.replaceChildren(new Option("Choose…", ""));
for (const { value, label } of options) {
region.add(new Option(label, value));
}
region.value = options.some((o) => o.value === previous) ? previous : "";
syncRegionValidity();
}
// Stamp a custom validity string when the pairing is impossible.
// An empty string clears the error and marks the field valid.
function syncRegionValidity(): void {
const legal = new Set((REGIONS[country.value] ?? []).map((o) => o.value));
if (!country.value) {
region.setCustomValidity("Select a country first.");
} else if (region.value && !legal.has(region.value)) {
region.setCustomValidity("This region is not valid for the chosen country.");
} else {
region.setCustomValidity(""); // valid
}
}
country.addEventListener("change", repopulateRegion);
region.addEventListener("change", syncRegionValidity);
form.addEventListener("submit", (event) => {
syncRegionValidity(); // recompute before gating
if (!form.reportValidity()) { // one manual call surfaces native UI
event.preventDefault();
}
});
repopulateRegion(); // initialise on load
Because the pairing rule lives in setCustomValidity, the field participates in form.reportValidity() exactly like a native required constraint, and you get browser-rendered bubbles for free. Swap the string for richer custom validity messages as needed.
setCustomValidity Configuration Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
REGIONS map |
Record<string, Option[]> |
{} |
Authoritative parent → legal child values |
preserveSelection |
boolean |
true |
Keep child value when it remains legal after a parent change |
emptyChildMessage |
string |
"Select a country first." |
Validity text when no parent is chosen yet |
illegalPairMessage |
string |
"…not valid for the chosen country." |
Validity text for a stale/impossible pairing |
resetChildOnChange |
boolean |
false |
Force the child back to "" on every parent change |
revalidateOn |
"change" | "input" |
"change" |
Event that re-runs syncRegionValidity |
Verification Steps
import { test, expect } from "@playwright/test";
test("stale region pairing is rejected at submit", async ({ page }) => {
await page.goto("/demo/dependent-dropdown");
await page.selectOption("#country", "us");
await page.selectOption("#region", "ny");
await page.selectOption("#country", "ca"); // "ny" now illegal
await page.click('button[type="submit"]');
const message = await page.$eval(
"#region",
(el) => (el as HTMLSelectElement).validationMessage,
);
expect(message).toContain("not valid");
});
Edge Cases & Failure Modes
Stale child value survives a parent switch. If you rebuild options but forget to re-run syncRegionValidity, the child keeps a now-illegal value with no error. Always call the validity sync at the end of repopulateRegion, and re-run it inside the submit handler as a backstop.
Programmatic value changes bypass the change event. Setting region.value in code does not dispatch change, so validity never updates. Call syncRegionValidity() directly after any scripted assignment, or dispatch a synthetic new Event("change").
Client enumeration drifts from the server’s truth. A hardcoded REGIONS map can fall behind the backend. Treat the client rule as a fast guard and re-check on the server, or source the option set through asynchronous server checks; for schema-driven parity consider Zod schema validation.
Chaining More Than Two Levels
The two-select recipe generalises to a chain — country → state → city — without new machinery, because each link is still a pure function from one value to the next legal set. The trap is forgetting to cascade the reset: changing the country must invalidate not only the state but the city that hung off the old state. Model the dependency as an ordered list and rebuild every downstream level from the level that changed, so a single edit propagates all the way down.
// Each level declares its parent and a lookup keyed by the parent's value.
interface Level {
select: HTMLSelectElement;
optionsFor: (parentValue: string) => { value: string; label: string }[];
}
// Ordered parent -> child; index 0 has no parent above it.
const chain: Level[] = [
{ select: country, optionsFor: () => COUNTRIES },
{ select: state, optionsFor: (c) => STATES[c] ?? [] },
{ select: city, optionsFor: (s) => CITIES[s] ?? [] },
];
// Rebuild `startIndex` and everything below it. Called with the index of
// the level whose value just changed, plus one, so the change itself stays.
function cascade(startIndex: number): void {
for (let i = startIndex; i < chain.length; i++) {
const { select, optionsFor } = chain[i];
const parentValue = chain[i - 1]?.select.value ?? "";
const options = optionsFor(parentValue);
const previous = select.value;
select.replaceChildren(new Option("Choose…", ""));
for (const { value, label } of options) select.add(new Option(label, value));
// Preserve only if still legal; otherwise blanking forces a fresh pick.
select.value = options.some((o) => o.value === previous) ? previous : "";
// A parent left blank makes every descendant structurally unsatisfiable.
const unmet = i > 0 && !parentValue;
select.setCustomValidity(unmet ? "Choose the level above first." : "");
}
}
chain.forEach((level, i) =>
level.select.addEventListener("change", () => cascade(i + 1)),
);
cascade(0);
The loop rebuilds strictly downstream, so an edit at level n never disturbs the user’s choices above it. Because each setCustomValidity call keeps every descendant in the validity pipeline, a partially filled chain fails reportValidity() on the first unmet level — the user is walked down the hierarchy one required step at a time rather than being handed three simultaneous errors.
Performance With Large Option Sets
A city list can run to thousands of entries, and rebuilding it on every parent change is where naive implementations stall. Three points matter. First, replaceChildren plus a loop of add() mutates the live DOM once per option; for very large sets, build the options in a DocumentFragment and attach it in a single insertion so layout is invalidated only once. Second, prefer setting option.value/option.textContent on pre-created nodes over parsing an innerHTML string — string parsing re-tokenises the markup and silently opens an injection seam if any label is user-derived. Third, keep the lookup itself O(1): a Map<string, Option[]> or plain object indexed by the parent value avoids scanning an array on every keystroke when revalidateOn is set to "input".
function fillFast(select: HTMLSelectElement, opts: readonly { value: string; label: string }[]): void {
const frag = document.createDocumentFragment();
frag.append(new Option("Choose…", ""));
for (const { value, label } of opts) {
const node = new Option();
node.value = value; // assigned, never interpolated into HTML
node.textContent = label; // textContent escapes automatically
frag.append(node);
}
select.replaceChildren(frag); // one DOM write, one reflow
}
If the legal set is genuinely huge, a native <select> is the wrong control — swap to a filterable combobox and validate its hidden input the same way, since the validity contract lives on the input, not the widget chrome. Whatever the control, resist recomputing the legal Set inside a hot handler: memoise it per parent value so syncRegionValidity stays a constant-time membership test.
Frequently Asked Questions
Should I disable the child dropdown until a parent is selected?
You can, but a disabled control is skipped by the Constraint Validation API, so a required child would not block submit. Prefer leaving it enabled with an empty "" option and a setCustomValidity message, which keeps the field in the validity pipeline while still signalling that a parent is needed.
Why not just reset the child to empty on every parent change?
Resetting is safe but hostile to users who re-select the same parent or switch between countries that share a region code. Preserving the prior value when it stays legal, as the previous check does, avoids needless re-entry while still clearing genuinely impossible pairings.
How do screen readers announce the changed region options?
Rebuilding <option> elements is silent, so pair the change with a visually rendered hint and follow the site's guidance on accessible error messaging. Validate the result with axe-core accessibility testing to catch missing labels.
Does rebuilding options break an open native select dropdown?
Mutating a child <select> while its own popup is open can collapse it mid-interaction, but the cascade here fires on the parent's change event, which only resolves once the parent's popup has closed. That ordering means the child is repopulated while it is idle, so there is no visible flicker. Avoid rebuilding a select inside its own input handler for exactly this reason.
Related Guides
- Cross-Field Validation Strategies — the parent section covering every field-to-field dependency.
- Validating a Date Range (Start Before End) — a sibling recipe for ordering rules between two inputs.
- Cross-Field Password Confirmation Logic — the equality-constraint counterpart to this recipe.
- Zod superRefine for Cross-Field Rules — express the same parent-child rule declaratively in a schema.
- Composing Pure Validator Functions — factor the legality check into a reusable, testable unit.