Confirming a password is the canonical cross-field rule, and the bug it produces is always the same: the user fixes the password, the confirm field still reads “passwords do not match,” and nothing clears it. The rule is trivial (password === confirm); the correct part is the dependency wiring — which field owns the error, which changes trigger revalidation, and how the mismatch is announced accessibly.


Problem Scope

Validate that confirm equals password, keep the result correct when either field changes after the other was filled, and announce the mismatch on the field the user must fix.


Context and Prerequisites

This is a specific case of the ordering and trigger problem covered in cross-field dependency logic: a rule whose result depends on two fields must re-run when either input changes, and its error must land on a chosen field path rather than the form root. The equality itself is expressed with a schema refinement, so the setup in integrating Zod for schema validation is the foundation this builds on. The whole pattern hinges on one asymmetry: the rule lives on confirm, but password is a co-trigger for it.


Core Pattern

Express the equality at the object level and re-target its error onto confirm with path. Then wire the form layer so that editing password revalidates confirm.

import { z } from "zod";

export const passwordForm = z
  .object({
    password: z.string().min(10, "Use at least 10 characters"),
    confirm: z.string().min(1, "Confirm your password"),
  })
  // Attach the cross-field check at the object level so it can see both values,
  // then route the issue to the confirm path — that is the field the user fixes.
  .refine((data) => data.password === data.confirm, {
    message: "Passwords do not match",
    path: ["confirm"], // without this, the error lands on the form root
  });

export type PasswordForm = z.infer<typeof passwordForm>;

The schema is only half the pattern. The other half is the trigger graph in the form layer. A field-scoped validator that only re-runs the rule for the field that changed will never clear the mismatch when password is edited — because the rule is registered on confirm.

// A minimal, framework-agnostic validator with a dependency trigger map.
// The key idea: some fields, when changed, must revalidate OTHER fields.
type Values = { password: string; confirm: string };

// When the key field changes, also revalidate every field in its list.
const revalidateTriggers: Record<keyof Values, (keyof Values)[]> = {
  password: ["confirm"], // editing password must re-check confirm's equality rule
  confirm: [],           // editing confirm only re-checks itself
};

function validateFields(values: Values, changed: keyof Values): Record<string, string> {
  // Always revalidate the changed field plus everything that depends on it.
  const toCheck = new Set<keyof Values>([changed, ...revalidateTriggers[changed]]);
  const result = passwordForm.safeParse(values);
  const errors: Record<string, string> = {};

  if (!result.success) {
    for (const issue of result.error.issues) {
      const field = issue.path[0] as keyof Values | undefined;
      // Only surface errors for fields in the revalidation set so untouched
      // fields are not flagged by an unrelated keystroke.
      if (field && toCheck.has(field)) {
        errors[field] = issue.message;
      }
    }
  }
  return errors;
}

Accessible announcement

The mismatch must reach assistive technology. Set aria-invalid on the confirm input and point aria-describedby at a live error node, following the conventions in accessibility and error UX.

// Reflect the confirm error into the DOM accessibly.
// aria-live="assertive" on the error node makes the mismatch announced the
// moment it appears, so a screen reader user is not left guessing.
function renderConfirmError(input: HTMLInputElement, message: string | undefined): void {
  const errorId = `${input.id}-error`;
  let node = document.getElementById(errorId);

  if (message) {
    input.setAttribute("aria-invalid", "true");
    input.setAttribute("aria-describedby", errorId);
    if (!node) {
      node = document.createElement("p");
      node.id = errorId;
      node.setAttribute("role", "alert"); // assertive announcement of the mismatch
      input.insertAdjacentElement("afterend", node);
    }
    node.textContent = message;
  } else {
    input.setAttribute("aria-invalid", "false");
    input.removeAttribute("aria-describedby");
    node?.remove();
  }
}

Wiring the trigger into a form hook

The trigger map is framework-agnostic, but each adapter expresses “revalidate this other field” differently. In React Hook Form the mechanism is the trigger API called from the password field’s change handler; in a custom reducer it is an explicit dependency lookup on every dispatch.

// React Hook Form: revalidate confirm whenever password changes.
// Without this, editing password leaves confirm's stale error in place, because
// RHF only revalidates the field that fired unless you tell it otherwise.
const { register, trigger, getFieldState } = useForm<PasswordForm>({
  resolver: zodResolver(passwordForm),
  mode: "onBlur",
});

const passwordProps = register("password", {
  onChange: () => {
    // Only re-run confirm's rule once confirm has been touched, so we do not
    // flash a mismatch before the user has even reached the confirm field.
    if (getFieldState("confirm").isTouched) {
      void trigger("confirm");
    }
  },
});

The getFieldState("confirm").isTouched guard is what implements the “compute always, reveal on touch” rule at the framework layer: the resolver still evaluates the schema on every change, but the visible revalidation of confirm is deferred until the user has committed to it. A custom hook built on the React form hook architecture applies the same idea by consulting the revalidateTriggers map inside its dispatch reducer.


Step-by-Step Walkthrough

  1. Define the schema with password and confirm fields and a .refine equality check targeting path: ["confirm"].
  2. Build the trigger map so a change to password revalidates confirm. This is the single line that fixes the “error won’t clear” bug.
  3. Validate on change by re-running the affected field set: the changed field plus its dependents.
  4. Gate visibility on touched — compute the error always, but only render it once confirm has been blurred or touched, so the mismatch does not flash on every intermediate keystroke.
  5. Announce accessibly by setting aria-invalid and aria-describedby on confirm, and rendering the message into a role="alert" node.
  6. Clear on match — when the values agree, remove aria-invalid, drop aria-describedby, and remove the error node.

The rule is symmetric but the reporting must not be. Which field carries the error decides whether the reader can act on it:

Where the mismatch error belongs On the password field: the reader is told their password is wrong, which is false — it is the confirmation that does not match — and acting on it means retyping the secret they had already chosen. On both fields: two identical messages are rendered and announced, doubling the noise for a screen reader reader while adding nothing, and the error summary now lists one problem twice. On the confirmation field alone: the message sits on the field the reader must change, focus moves there, and the summary lists one problem once. That is the only arrangement where reading the message tells you what to do. on the password field claims the password is wrong it is not — the copy is acting on it means retyping the secret they had chosen wrong field, wrong action on both fields two identical messages announced twice summary lists one problem as if it were two noise, no extra information on the confirmation sits on the field to change focus moves there summary lists it once reading it says what to do the only actionable option The rule reads both fields; the issue is attached to one. Schema libraries make this explicit — the path decides the field. Same reasoning for date ranges: the error belongs on the end date, not the start the reader already committed to.

Failure Modes and Edge Cases

Mismatch error survives a password edit. The classic bug: the rule is on confirm, so editing password never re-runs it. Fix with the trigger map.

// WRONG: only revalidate the field that changed.
const errors = validateOnly(changedField, values); // password edit never re-checks confirm

// RIGHT: revalidate the changed field AND its dependents.
const errors = validateFields(values, changedField); // password -> also checks confirm

Error flashes on the first keystroke of confirm. Rendering the mismatch before the user has finished typing is noise. Gate rendering on a touched flag while still computing the rule.

Trailing-whitespace false mismatch. A trailing space from autofill or paste makes visually identical values compare unequal. Decide the policy explicitly — for passwords, usually do not trim, since whitespace is a legitimate password character, but be aware that autofill can introduce it.

Password manager fills confirm before password. Some managers populate confirm first, momentarily producing a mismatch. Debounce the visible error briefly on programmatic fills, or suppress until both fields are non-empty.

Announcement storms on every keystroke. If the role="alert" node’s text is rewritten on each character, screen readers re-announce repeatedly. Only update the node when the message string actually changes, not on every validation pass.

// Guard the announcement: only touch the DOM node when the message differs,
// so an unchanged mismatch is not re-announced on every keystroke.
let lastMessage: string | undefined;
function announceIfChanged(input: HTMLInputElement, message: string | undefined): void {
  if (message === lastMessage) return; // no-op keeps the live region quiet
  lastMessage = message;
  renderConfirmError(input, message);
}

Three-field change-password forms. A change-password form adds a current-password field, and the equality rule still only concerns the new pair. Do not let the current-password field participate in the match rule or trigger confirm revalidation — scope the trigger map so only the new password co-triggers confirm, or the current-password field will spuriously re-run the mismatch check.

Submit bypasses the touched gate. A user can submit via keyboard without ever blurring confirm, leaving the touched flag false and the mismatch hidden. On submit, force every field to touched before rendering errors so the mismatch surfaces even when the field was never individually blurred.


Timing matters as much as placement here, because a symmetric rule can fire while only half of its inputs exist:

Do not report a mismatch the reader has not finished making The reader types the password, then moves to the confirmation and types its first character. At that point the two values differ, but reporting it would mark the field invalid on every partial entry — the reader is scolded for not having finished typing. Nothing is shown until the confirmation field is left for the first time. From then on, every keystroke re-checks, so the error clears the instant the values match rather than waiting for another blur, which is exactly the feedback a reader repairing a mismatch needs. typing password confirmation empty show nothing typing confirmation values differ, but still show nothing first blur now the rule may report a mismatch after that re-check every keystroke One more edge: editing the password after both are filled Changing the password re-breaks the match, so the confirmation's error must be re-evaluated even though nobody touched it. That is the dependency edge: password is an input to the confirmation's rule, so a write to it invalidates that rule. Announce the re-broken match politely — the reader is looking at the password field, not the confirmation.

Verification Checklist


What the pattern costs a password manager

A confirmation field interacts with autofill in ways that are easy to break, and readers who use a manager are exactly the readers whose passwords you least want to disrupt.

Three ways a password manager fills these two fields Both fields filled in one action: the manager writes both values in the same tick, so a rule that only runs on blur never runs; validation must also run on the input events the fill dispatches. Confirmation filled first: some managers fill in DOM order and others in their own, so the rule must not assume the password is present when the confirmation changes. A paste with no keystrokes: pasting fires input but not keydown, so any logic hung off keydown — a common way to implement a debounce — never runs, and the field appears unvalidated. What the manager does Why it breaks a naive rule What to do fills both at once no blur ever happens validate on input too fills confirmation first password is still empty skip the rule, do not fail it pastes with no keystrokes keydown never fires never hang logic off keydown Row two is the one that produces angry reports: the form declares a mismatch against an empty password the reader never saw. "One input is empty" is not a mismatch — it is an incomplete comparison, and the rule should return no issue at all. Keep autocomplete="new-password" on both fields so the manager offers to generate and fill rather than guessing.

Frequently Asked Questions

Why does the mismatch error not clear when I fix the password field?

Because you only revalidate the field that changed. Editing password does not re-run the rule that lives on confirm, so its stale error persists. Register password as a trigger that revalidates confirm, so any change to either field re-evaluates the equality rule.

Should the error attach to password or confirm?

To confirm. The confirm field is where the user is asked to reconcile the values, so that is where the message belongs and where focus and aria-describedby should point. Attaching it to password, or to the form root, leaves the announced error disconnected from the input the user must fix.

How do I stop the mismatch error showing before the user has finished typing?

Gate the message on the confirm field being touched or blurred. Compute the equality rule continuously so state is correct, but only surface it once the user has committed to the confirm field, which avoids flashing a mismatch on every intermediate keystroke.


Related

Cross-Field Dependency Logic