When a parent field changes, clears only the dependent values it made invalid and names each one with Undo, so fields don't vanish silently.
StopsDependent fields cleared without notice
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
DependentResetNotice,
clearedFieldClass,
useDependentReset,
type DependentRules,
} from "@/components/ui/dependent-reset"
const PLACES: Record<string, Record<string, string[]>> = {
India: { Maharashtra: ["Pune", "Mumbai"], Karnataka: ["Bengaluru", "Mysuru"] },
"United States": { California: ["San Francisco", "Los Angeles"], "New York": ["New York City", "Buffalo"] },
}
const PLANS: Record<string, string[]> = {
Starter: ["Priority support"],
Pro: ["Priority support", "Analytics"],
Business: ["Priority support", "Analytics", "SSO"],
}
type Order = { country: string; state: string; city: string; plan: string; addons: string[] }
const rules: DependentRules<Order> = {
state: { dependsOn: ["country"], isValid: (state, v) => state in (PLACES[v.country] ?? {}) },
city: { dependsOn: ["country", "state"], isValid: (city, v) => (PLACES[v.country]?.[v.state] ?? []).includes(city) },
addons: { dependsOn: ["plan"], prune: (addons, v) => addons.filter((a) => PLANS[v.plan].includes(a)) },
}
const select = cn(
"h-9 w-full min-w-0 rounded-md border bg-background px-3 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30",
clearedFieldClass,
)
export function Demo() {
const form = useDependentReset<Order>({
defaultValue: { country: "India", state: "Maharashtra", city: "Pune", plan: "Business", addons: ["Priority support", "SSO"] },
rules,
labels: { country: "Country", state: "State", city: "City", plan: "Plan", addons: "Add-ons" },
})
const { values, setField } = form
const id = React.useId()
// The demo opens right after someone switched the country.
React.useEffect(() => setField("country", "United States"), []) // eslint-disable-line react-hooks/exhaustive-deps
const field = (name: "country" | "state" | "city", label: string, options: string[]) => (
<div className="grid gap-1.5">
<label htmlFor={`${id}-${name}`} className="w-fit text-sm font-medium">{label}</label>
<select id={`${id}-${name}`} value={values[name]} onChange={(e) => setField(name, e.target.value)} data-cleared={form.isCleared(name) ? "" : undefined} className={cn(select, !values[name] && "text-muted-foreground")}>
<option value="" disabled>{form.previousValue(name) ? `Was ${form.previousValue(name)}` : `Choose ${label.toLowerCase()}`}</option>
{options.map((o) => <option key={o}>{o}</option>)}
</select>
</div>
)
return (
<form className="grid w-full max-w-sm gap-3" onSubmit={(e) => e.preventDefault()}>
<DependentResetNotice notice={form.notice} onUndo={form.undo} onDismiss={form.dismiss} />
{field("country", "Country", Object.keys(PLACES))}
<div className="grid grid-cols-2 gap-3">
{field("state", "State", Object.keys(PLACES[values.country] ?? {}))}
{field("city", "City", PLACES[values.country]?.[values.state] ?? [])}
</div>
<fieldset className="grid gap-1.5">
<legend className="mb-1.5 text-sm font-medium">Plan</legend>
<div className="grid grid-cols-3 gap-1 rounded-md bg-muted p-1">
{Object.keys(PLANS).map((plan) => (
<button
key={plan}
type="button"
aria-pressed={values.plan === plan}
onClick={() => setField("plan", plan)}
className="h-7 rounded-sm text-sm font-medium text-muted-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:shadow-xs"
>
{plan}
</button>
))}
</div>
</fieldset>
<fieldset data-cleared={form.isCleared("addons") ? "" : undefined} className={cn("grid gap-2 rounded-md border p-3", clearedFieldClass)}>
<legend className="px-1 text-sm font-medium">Add-ons</legend>
{PLANS.Business.map((addon) => {
const available = PLANS[values.plan].includes(addon)
return (
<label key={addon} className={cn("flex items-center gap-2 text-sm", !available && "text-muted-foreground")}>
<input
type="checkbox"
disabled={!available}
checked={values.addons.includes(addon)}
onChange={(e) => setField("addons", e.target.checked ? [...values.addons, addon] : values.addons.filter((a) => a !== addon))}
className="size-4 accent-primary"
/>
{addon}
{!available && <span className="text-xs">· not on {values.plan}</span>}
</label>
)
})}
</fieldset>
</form>
)
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/uidependent-reset.tsx255 lines"use client" // Bollard UI — dependent-reset · MIT licence · https://bollard-ui.vercel.app/components/dependent-reset import * as React from "react" import { ListRestart, Undo2, X } from "lucide-react" import { cn } from "@/lib/utils" type Values = Record<string, unknown> /** How one field depends on others. */ export interface DependentRule<T extends Values, K extends keyof T = keyof T> { /** Fields whose change can make this one invalid. */ dependsOn: (keyof T)[] /** Whether the current value still makes sense for the new values. Invalid values are cleared. */ isValid?: (value: T[K], values: T) => boolean /** For lists: keep only the items that still fit (plan → add-ons), instead of clearing them all. */ prune?: (value: T[K], values: T) => T[K] /** How the old value reads in the notice. Defaults to the value, or the items joined. */ format?: (value: T[K]) => string } export type DependentRules<T extends Values> = { [K in keyof T]?: DependentRule<T, K> } const isEmpty = (value: unknown) => value === "" || value == null || (Array.isArray(value) && value.length === 0) const emptyLike = (value: unknown) => (Array.isArray(value) ? [] : "") const show = (value: unknown) => (Array.isArray(value) ? value.join(", ") : String(value ?? "")) /** * Applies a change and clears, in dependency order, every downstream field the * change made invalid (country → state → city). Fields that are still valid, * and the still-valid items of a list, are kept. */ export function cascadeReset<T extends Values>(values: T, changed: keyof T, rules: DependentRules<T>) { const next = { ...values } const dirty = new Set<keyof T>([changed]) const cleared: { name: keyof T; previous: T[keyof T]; partial: boolean }[] = [] for (let found = true; found; ) { found = false for (const name of Object.keys(rules) as (keyof T)[]) { const rule = rules[name] as DependentRule<T> | undefined if (!rule || dirty.has(name) || isEmpty(next[name]) || !rule.dependsOn.some((d) => dirty.has(d))) continue const kept = rule.prune ? rule.prune(next[name], next) : rule.isValid?.(next[name], next) === false ? (emptyLike(next[name]) as T[keyof T]) : next[name] if (show(kept) === show(next[name])) continue cleared.push({ name, previous: next[name], partial: !isEmpty(kept) }) next[name] = kept dirty.add(name) found = true } } return { values: next, cleared } } export interface ResetNotice<T extends Values> { /** Increments per change, so the notice re-announces and re-animates. */ id: number cause: keyof T causeLabel: string /** Each field that lost its value: label, old value as text, what went, and whether only part of it went. */ fields: { name: keyof T; label: string; previous: string; removed: string; partial: boolean }[] /** The values just before the change, for undo. */ before: T } export interface UseDependentResetOptions<T extends Values> { /** Controlled values. */ value?: T /** Initial values when uncontrolled. */ defaultValue: T /** Called with all values after every change, cascade included. */ onValueChange?: (values: T) => void /** Which fields depend on which, and when their value stops making sense. */ rules: DependentRules<T> /** Field names as the user sees them. */ labels?: Partial<Record<keyof T, string>> /** How long cleared fields stay highlighted, in ms. */ highlightFor?: number } /** * Form state that never wipes a dependent field silently. `setField` clears * what the change invalidated, raises a notice that names each lost value, * highlights those fields for a moment, and `undo` puts everything back. */ export function useDependentReset<T extends Values>({ value, defaultValue, onValueChange, rules, labels = {}, highlightFor = 1600 }: UseDependentResetOptions<T>) { const [inner, setInner] = React.useState(defaultValue) const [notice, setNotice] = React.useState<ResetNotice<T> | null>(null) const [highlighted, setHighlighted] = React.useState<(keyof T)[]>([]) const values = value ?? inner const latest = React.useRef(values) React.useEffect(() => { latest.current = values }, [values]) const label = (name: keyof T) => labels[name] ?? String(name) React.useEffect(() => { if (!highlighted.length) return const timer = setTimeout(() => setHighlighted([]), highlightFor) return () => clearTimeout(timer) }, [highlighted, highlightFor]) // Once every cleared field has been filled in again, the notice has done its job. React.useEffect(() => { const emptied = notice?.fields.filter((f) => !f.partial) ?? [] if (emptied.length && emptied.every((f) => !isEmpty(values[f.name]))) setNotice(null) }, [notice, values]) const commit = (next: T) => { latest.current = next if (value === undefined) setInner(next) onValueChange?.(next) } const setField = <K extends keyof T>(name: K, fieldValue: T[K]) => { const before = latest.current if (show(before[name]) === show(fieldValue)) return const result = cascadeReset({ ...before, [name]: fieldValue }, name, rules) commit(result.values) if (!result.cleared.length) return const fields = result.cleared.map(({ name: field, previous, partial }) => { const format = (rules[field] as DependentRule<T> | undefined)?.format ?? show const now = result.values[field] const removed = Array.isArray(previous) && Array.isArray(now) ? previous.filter((item) => !now.includes(item)) : previous return { name: field, label: label(field), previous: format(previous), removed: format(removed as T[keyof T]), partial } }) setNotice((last) => ({ id: (last?.id ?? 0) + 1, cause: name, causeLabel: label(name), before, fields })) setHighlighted(result.cleared.map((c) => c.name)) } const undo = () => { if (!notice) return const restored: T = { ...latest.current, [notice.cause]: notice.before[notice.cause] } for (const field of notice.fields) restored[field.name] = notice.before[field.name] commit(restored) setNotice(null) setHighlighted([]) } return { values, setField, notice, undo, dismiss: () => setNotice(null), /** True for a moment after the field was cleared: put it on `data-cleared` to highlight it. */ isCleared: (name: keyof T) => highlighted.includes(name), /** The value a field had before it was cleared, while the notice is up. Handy as a placeholder. */ previousValue: (name: keyof T) => notice?.fields.find((f) => f.name === name && !f.partial)?.previous, } } /** Add to a dependent field so it glows briefly while `data-cleared` is set. */ export const clearedFieldClass = "transition-[box-shadow,border-color] duration-300 ease-out data-cleared:border-amber-500 data-cleared:ring-[3px] data-cleared:ring-amber-500/35 dark:data-cleared:border-amber-400" export interface DependentResetNoticeProps<T extends Values> extends Omit<React.ComponentProps<"div">, "children"> { /** The notice from `useDependentReset`. */ notice: ResetNotice<T> | null /** Restore the change and everything it cleared. */ onUndo: () => void /** Close the notice and keep the new values. */ onDismiss: () => void /** `{count}` ("2 fields") and `{cause}` ("Country") are filled in. */ message?: string /** Word for one field and for several. */ fieldWord?: [singular: string, plural: string] /** Shown after the items a list lost. */ removedSuffix?: string /** Read to screen readers after a fully cleared value. */ clearedSuffix?: string /** Button labels. */ undoLabel?: string /** Accessible label for the dismiss button. */ dismissLabel?: string } /** * Says exactly what a change wiped ("2 fields cleared because you changed * Country"), strikes through each lost value, and offers Undo. It keeps an * empty live region mounted, so the next notice is announced. */ export function DependentResetNotice<T extends Values>({ notice, onUndo, onDismiss, message = "{count} cleared because you changed {cause}.", fieldWord = ["field", "fields"], removedSuffix = "removed", clearedSuffix = "cleared", undoLabel = "Undo", dismissLabel = "Dismiss", className, ...props }: DependentResetNoticeProps<T>) { const count = notice?.fields.length ?? 0 const muted = "text-amber-900/75 dark:text-amber-100/75" return ( <div data-slot="dependent-reset" role="status" aria-live="polite" className={cn("empty:hidden", className)} {...props}> {notice && ( <div key={notice.id} className="overflow-hidden rounded-lg border border-amber-500/30 bg-background bg-linear-to-r from-amber-500/10 to-amber-500/10 text-sm text-amber-950 motion-safe:animate-[cui-dependent-reset-in_250ms_ease-out] dark:text-amber-50" > <style>{"@keyframes cui-dependent-reset-in{from{opacity:0;transform:translateY(-6px)}}@keyframes cui-dependent-reset-strike{from{background-size:0 1px}}"}</style> <div className="flex min-h-11 items-center gap-3 px-3 py-1.5"> <ListRestart aria-hidden className="size-4 shrink-0 text-amber-600 dark:text-amber-400" /> <p className="min-w-0 flex-1 font-medium"> {message.replace("{count}", `${count} ${fieldWord[count === 1 ? 0 : 1]}`).replace("{cause}", notice.causeLabel)} </p> <button type="button" onClick={onDismiss} aria-label={dismissLabel} className="-mr-1 grid size-8 shrink-0 place-items-center rounded-md transition-colors outline-none hover:bg-amber-500/15 focus-visible:ring-[3px] focus-visible:ring-ring/50" > <X aria-hidden className="size-4" /> </button> </div> <div className="flex flex-wrap items-center gap-2 border-t border-amber-500/20 px-3 py-2"> <ul className="flex min-w-0 flex-1 flex-wrap gap-1.5 text-xs"> {notice.fields.map((field) => { const lost = field.partial ? field.removed : field.previous return ( <li key={String(field.name)} className="inline-flex max-w-full min-w-0 items-center gap-1 rounded-md bg-amber-500/10 px-2 py-1"> <span className={muted}>{field.label}:</span> <s aria-label={`${lost} ${field.partial ? removedSuffix : clearedSuffix}`} className="truncate bg-linear-to-r from-current to-current bg-[length:100%_1px] bg-center bg-no-repeat no-underline motion-safe:animate-[cui-dependent-reset-strike_400ms_ease-out_150ms_backwards]" > {lost} </s> {field.partial && <span className={muted}>{removedSuffix}</span>} </li> ) })} </ul> <button type="button" onClick={onUndo} className="ml-auto inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-amber-700/25 px-3 font-medium transition-colors outline-none hover:bg-amber-500/15 focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:border-amber-300/25" > <Undo2 aria-hidden className="size-4" /> {undoLabel} </button> </div> </div> )} </div> ) }Update the import paths to match your project setup.
It uses the
cn()helper from@/lib/utils, which every shadcn/ui project already has.
Usage
import { DependentResetNotice, clearedFieldClass, useDependentReset, type DependentRules, } from "@/components/ui/dependent-reset"
<DependentResetNotice notice={form.notice} onUndo={form.undo} onDismiss={form.dismiss} />What it catches
- Dependent values that became invalid after a parent change
- Partial loss (some options pruned) versus a field cleared entirely
- Silent data loss, by naming each value and offering Undo
Props
| Prop | Type | Default |
|---|---|---|
notice* (required)The notice from `useDependentReset`. | ResetNotice<T> | null | — |
onUndo* (required)Restore the change and everything it cleared. | () => void | — |
onDismiss* (required)Close the notice and keep the new values. | () => void | — |
message`{count}` ("2 fields") and `{cause}` ("Country") are filled in. | string | "{count} cleared because you changed {cause}." |
fieldWordWord for one field and for several. | [singular: string, plural: string] | ["field", "fields"] |
removedSuffixShown after the items a list lost. | string | "removed" |
clearedSuffixRead to screen readers after a fully cleared value. | string | "cleared" |
undoLabelButton labels. | string | "Undo" |
dismissLabelAccessible label for the dismiss button. | string | "Dismiss" |
* required. Also accepts the native props of <div> (className, aria-*, data-*…), passed through to it.
Accessibility
- The notice is role="status" with aria-live="polite"
- Each lost field is described in text (e.g. "cleared" or "removed")
- Dismiss has an aria-label
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
- You define which values are valid for each parent value
- Undo restores the previous values in the form; it doesn't undo anything already saved
What it stops
Dependent fields cleared without notice
How people describe it
Also known as
dependent fields · cascading select · country state city · conditional fields · add-ons · plan picker · react hook form
Field type