When a conditional form section is hidden, it names the fields still filled in there, so old values don't ride along unseen.
StopsHidden field still submitted old value
"use client"
import * as React from "react"
import { HiddenFieldGuard } from "@/components/ui/hidden-field-guard"
const input =
"h-9 w-full min-w-0 rounded-md border bg-transparent px-3 text-base shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm dark:bg-input/30"
export function Demo() {
const [different, setDifferent] = React.useState(false)
const [address, setAddress] = React.useState({ street: "14 Lake Road", city: "Pune", postcode: "411039" })
const [sent, setSent] = React.useState<string>()
const set = (key: keyof typeof address) => (event: React.ChangeEvent<HTMLInputElement>) =>
setAddress((current) => ({ ...current, [key]: event.target.value }))
return (
<form
className="grid w-full max-w-sm gap-3"
onSubmit={(event) => {
event.preventDefault()
const data = [...new FormData(event.currentTarget).entries()].filter(([, value]) => value !== "")
setSent(data.map(([key]) => key).join(", "))
}}
>
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
checked={different}
onChange={(event) => setDifferent(event.target.checked)}
className="size-4 accent-primary"
/>
Ship to a different address
</label>
<HiddenFieldGuard hidden={!different} label="Shipping address">
<div className="grid gap-2 rounded-lg border p-3">
<label className="grid gap-1.5 text-sm">
<span className="font-medium">Street</span>
<input name="street" value={address.street} onChange={set("street")} className={input} />
</label>
<div className="grid grid-cols-2 gap-2">
<label className="grid gap-1.5 text-sm">
<span className="font-medium">City</span>
<input name="city" value={address.city} onChange={set("city")} className={input} />
</label>
<label className="grid gap-1.5 text-sm">
<span className="font-medium">Postcode</span>
<input name="postcode" value={address.postcode} onChange={set("postcode")} className={input} />
</label>
</div>
<label className="grid gap-1.5 text-sm">
<span className="font-medium">Country</span>
<select name="country" defaultValue="IN" className={input}>
<option value="IN">India</option>
<option value="AE">United Arab Emirates</option>
</select>
</label>
</div>
</HiddenFieldGuard>
<div className="flex items-center justify-between gap-3">
<span className="min-w-0 truncate text-xs text-muted-foreground" aria-live="polite">
{sent === undefined ? "" : `Sent: ${sent || "nothing"}`}
</span>
<button
type="submit"
className="inline-flex h-9 shrink-0 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow-xs outline-none hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Place order
</button>
</div>
</form>
)
}Installation
The files land in your project. They're yours to edit.
Install the dependencies
Copy and paste the following code into
components/uihidden-field-guard.tsx198 lines"use client" // Bollard UI — hidden-field-guard · MIT licence · https://bollard-ui.vercel.app/components/hidden-field-guard import * as React from "react" import { EyeOff, Undo2 } from "lucide-react" import { cn } from "@/lib/utils" /** Banner layout shared by every Bollard UI banner: one height, padding, icon size and action style. */ const bar = "flex min-h-11 flex-wrap items-center gap-x-3 gap-y-1.5 px-3 py-1.5 text-sm" const action = "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50" const warning = { surface: "border-amber-500/30 bg-background bg-linear-to-r from-amber-500/10 to-amber-500/10 text-amber-950 dark:text-amber-50", icon: "text-amber-600 dark:text-amber-400", muted: "text-amber-900/75 dark:text-amber-100/75", solid: "bg-amber-700 text-white hover:bg-amber-800 dark:bg-amber-400 dark:text-amber-950 dark:hover:bg-amber-300", outline: "border border-amber-700/25 hover:bg-amber-500/15 dark:border-amber-300/25", } type Field = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement type FieldValue = string | boolean type Saved = { el: Field; value: FieldValue } const SKIPPED = new Set(["hidden", "submit", "button", "reset", "image", "file"]) const isToggle = (el: Field): el is HTMLInputElement => el.type === "checkbox" || el.type === "radio" const read = (el: Field): FieldValue => (isToggle(el) ? el.checked : el.value) /** Sets a field the way a user would, so React `onChange` handlers and controlled state see it. */ function write(el: Field, value: FieldValue) { const proto = Object.getPrototypeOf(el) as object if (isToggle(el)) { Object.getOwnPropertyDescriptor(proto, "checked")?.set?.call(el, value) el.dispatchEvent(new Event("click", { bubbles: true })) } else { Object.getOwnPropertyDescriptor(proto, "value")?.set?.call(el, value) el.dispatchEvent(new Event("input", { bubbles: true })) } el.dispatchEvent(new Event("change", { bubbles: true })) } /** A field's human name: its label, aria-label, placeholder, then name. */ export function fieldName(el: Field) { const text = el.labels?.[0]?.textContent ?? el.getAttribute("aria-label") ?? el.getAttribute("placeholder") ?? el.name return (text ?? "").replace(/\s*\*\s*$/, "").trim() || "Untitled field" } /** "Street", "Street and City", "Street, City and 2 more". */ export function listNames(names: string[], max = 2) { if (names.length <= max + 1) return names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : (names[0] ?? "") return `${names.slice(0, max).join(", ")} and ${names.length - max} more` } const fill = (template: string, values: Record<string, string | number>) => template.replace(/\{(\w+)\}/g, (match, key: string) => String(values[key] ?? match)) export interface HiddenFieldGuardProps extends Omit<React.ComponentProps<"div">, "hidden"> { /** Whether the section is currently hidden (toggle off, another tab or step selected). */ hidden: boolean /** The section's name, e.g. "Shipping address". */ label: string /** * What happens to hidden values on submit. `submitted`: they're still sent. * `discarded`: the section is disabled, so the browser leaves them out. */ hiddenValues?: "submitted" | "discarded" /** Called after Clear with the names of the fields that were emptied. */ onClear?: (names: string[]) => void /** Templates: `{label}`, `{count}` and `{fields}` ("3 filled fields") are filled in. */ message?: string /** Detail line. `{names}` is filled in. */ submittedText?: string /** Shown when hidden values will be dropped on submit. */ discardedText?: string /** Shown after the values are cleared. */ clearedText?: string /** Clear button text. */ clearLabel?: string /** Keep button text. */ keepLabel?: string /** Undo button text. */ undoLabel?: string } /** * Catches values that ride along unseen. When a conditional section is * hidden, its fields stay mounted (so nothing is silently lost) and, if any * still differ from how they started, a notice names them and says whether * they'll be submitted or dropped. Keep is remembered until those values * change; Clear resets each field through real input events, so controlled * React state follows, and can be undone. */ export function HiddenFieldGuard({ hidden, label, hiddenValues = "submitted", onClear, message = "{label} is hidden but still has {fields}.", submittedText = "{names} will still be submitted.", discardedText = "{names} won't be sent while it's hidden.", clearedText = "Cleared {fields} from {label}.", clearLabel = "Clear them", keepLabel = "Keep", undoLabel = "Undo", className, children, ...props }: HiddenFieldGuardProps) { const sectionRef = React.useRef<HTMLFieldSetElement>(null) const baseline = React.useRef(new WeakMap<Field, FieldValue>()) const [filled, setFilled] = React.useState<Saved[]>([]) const [keptSignature, setKeptSignature] = React.useState<string>() const [cleared, setCleared] = React.useState<Saved[]>([]) const scan = React.useCallback(() => { const fields = [...(sectionRef.current?.querySelectorAll<Field>("input, select, textarea") ?? [])] return fields.filter((el) => { if (SKIPPED.has(el.type)) return false if (!baseline.current.has(el)) baseline.current.set(el, isToggle(el) ? false : "") const value = read(el) return value !== baseline.current.get(el) && value !== "" }).map((el) => ({ el, value: read(el) })) }, []) // Choices always have a value, so their starting option (a preselected country) is the empty state. React.useEffect(() => { sectionRef.current?.querySelectorAll<Field>("select, input[type=radio]").forEach((el) => baseline.current.set(el, read(el))) }, []) React.useEffect(() => { setFilled(hidden ? scan() : []) if (!hidden) setCleared([]) }, [hidden, scan]) const signature = JSON.stringify(filled.map((field) => field.value)) const names = filled.map((field) => fieldName(field.el)) const count = (list: unknown[]) => `${list.length} filled ${list.length === 1 ? "field" : "fields"}` const showWarning = hidden && filled.length > 0 && keptSignature !== signature const clear = () => { filled.forEach(({ el }) => write(el, baseline.current.get(el) ?? "")) setCleared(filled) setFilled([]) onClear?.(names) } const undo = () => { cleared.forEach(({ el, value }) => write(el, value)) setFilled(cleared) setKeptSignature(JSON.stringify(cleared.map((field) => field.value))) setCleared([]) } return ( <div data-slot="hidden-field-guard" data-state={showWarning ? "warning" : hidden ? "hidden" : "shown"} className={cn("grid gap-2", className)} {...props}> <style>{`@keyframes cui-hidden-field-guard-in{from{opacity:0;transform:translateY(-4px)}}`}</style> <fieldset ref={sectionRef} hidden={hidden} disabled={hidden && hiddenValues === "discarded"} className="m-0 min-w-0 border-0 p-0"> {children} </fieldset> <div aria-live="polite"> {showWarning && ( <div role="region" aria-label={`Hidden values in ${label}`} className={cn("rounded-lg border motion-safe:animate-[cui-hidden-field-guard-in_220ms_ease-out]", warning.surface)} > <div className={bar}> <div className="flex min-w-0 flex-1 basis-56 items-start gap-3 py-1"> <EyeOff aria-hidden className={cn("mt-0.5 size-4 shrink-0", warning.icon)} /> <p className="min-w-0"> <span className="font-medium">{fill(message, { label, fields: count(filled), count: filled.length })}</span>{" "} <span className={warning.muted}>{fill(hiddenValues === "submitted" ? submittedText : discardedText, { names: listNames(names) })}</span> </p> </div> <div className="ml-auto flex gap-2"> <button type="button" onClick={() => setKeptSignature(signature)} className={cn(action, warning.outline)}> {keepLabel} </button> <button type="button" onClick={clear} className={cn(action, warning.solid)}> {clearLabel} </button> </div> </div> </div> )} {hidden && cleared.length > 0 && ( <p className="flex items-center gap-2 text-sm text-muted-foreground motion-safe:animate-[cui-hidden-field-guard-in_220ms_ease-out]"> <span>{fill(clearedText, { label, fields: count(cleared), count: cleared.length })}</span> <button type="button" onClick={undo} className={cn(action, "h-7 px-2 text-foreground hover:bg-accent")}> <Undo2 aria-hidden className="size-3.5" /> {undoLabel} </button> </p> )} </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.
What it catches
- Values left in a section the user has hidden
- Whether those values will still be submitted
- Clearing that bypasses React state: it fires real input events instead
Props
| Prop | Type | Default |
|---|---|---|
hidden* (required)Whether the section is currently hidden (toggle off, another tab or step selected). | boolean | — |
label* (required)The section's name, e.g. "Shipping address". | string | — |
hiddenValuesWhat happens to hidden values on submit. `submitted`: they're still sent. `discarded`: the section is disabled, so the browser leaves them out. | "submitted" | "discarded" | "submitted" |
onClearCalled after Clear with the names of the fields that were emptied. | (names: string[]) => void | — |
messageTemplates: `{label}`, `{count}` and `{fields}` ("3 filled fields") are filled in. | string | "{label} is hidden but still has {fields}." |
submittedTextDetail line. `{names}` is filled in. | string | "{names} will still be submitted." |
discardedTextShown when hidden values will be dropped on submit. | string | "{names} won't be sent while it's hidden." |
clearedTextShown after the values are cleared. | string | "Cleared {fields} from {label}." |
clearLabelClear button text. | string | "Clear them" |
keepLabelKeep button text. | string | "Keep" |
undoLabelUndo button text. | string | "Undo" |
* required. Also accepts the native props of <div> (className, aria-*, data-*…), passed through to it.
Accessibility
- Banner is a labelled region inside a polite live region
- Field names come from the field's label, aria-label, placeholder or name
- Keep, Clear and Undo are buttons; icons are aria-hidden
Only what the source does. Test with your own screen reader and content before shipping.
Limitations
- Only sees native inputs inside the section's DOM; custom widgets that keep values elsewhere are missed
- Submitted vs dropped depends on the hiddenValues/submitted setting you pass, not on inspecting your submit code
What it stops
Hidden field still submitted old value
How people describe it
Also known as
conditional fields · hidden fields · show hide · react hook form · shouldUnregister · form state · toggle section