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:

Dependent Dropdown Validation Flow Parent selection filters the child option set, then a single Constraint Validation call confirms the pairing. One selection constrains the next, then validity confirms the pair Parent change country selected Repopulate child rebuild legal options Report validity reject stale pairing
The parent's change event drives option rebuilds, and one validity call rejects any leftover impossible pairing.

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.

← Back to Cross-Field Validation Strategies