Browser autofill writes values straight into the DOM, and when it does so without firing the events your controlled inputs listen to, the screen shows a filled form while your state still holds empty strings — so validation blocks a submit the user can see is complete.

The mechanics of who owns a field’s value are laid out in controlled vs uncontrolled forms. Autofill is the case that breaks the controlled model’s core promise: that state is the single source of truth and the DOM merely reflects it. Here the DOM changes first, and the question is how to pull that change back into state before anything reads it.


Context and prerequisites

Modern Chromium, Firefox and Safari do fire an input event when they autofill a text field in most cases, which is why autofill “mostly works” in React and Vue. The failures cluster around three situations:

  • Chrome’s pre-fill on page load. Chrome can paint saved credentials into fields before the user interacts with the page, but it withholds the actual value from script (el.value reads as "") until the user clicks or presses a key anywhere. This is a privacy measure, not a bug, and no event fires when the value becomes readable.
  • Safari and iOS keychain fills of several fields at once. The focused field gets its event; the sibling fields filled in the same gesture sometimes do not, particularly inside shadow DOM or when the fields were rendered after the page loaded.
  • Password managers implemented as extensions. They set value through the property setter, which bypasses React’s value tracker, and may or may not dispatch a synthetic input event afterwards.

So a robust form does not rely on any single signal. It listens for events, detects the autofill paint state through CSS, and — as a last line of defence — reads the DOM at submit time.

When an autofilled value becomes visible to script For Chrome page-load prefill the value is painted at about 100 milliseconds but script reads an empty string until the first user interaction around 1400 milliseconds, with no event at that moment. For a Safari multi-field fill the focused field fires input immediately while sibling fields may fire nothing. For an extension manager the value is set through the property setter at about 600 milliseconds and a synthetic event may follow later or never. Chrome prefill on load painted, but el.value reads empty readable after any click Safari multi-field fill focused field: input fires siblings: event may be missing Extension manager setter bypasses tracker synthetic event: maybe 0ms 500ms 1000ms 1500ms 2000ms Only the submit-time DOM read covers all three rows; events and CSS detection shrink the window in which state is wrong.

The core pattern: three layers of reconciliation

type Values = Record<string, string>;

/**
 * Keeps a controlled form's state in step with values the browser or a
 * password manager wrote into the DOM behind its back.
 */
export function attachAutofillSync(
  form: HTMLFormElement,
  getState: () => Values,
  setField: (name: string, value: string) => void,
): () => void {
  // Layer 1: CSS-driven detection. Chromium and WebKit apply :autofill
  // (and the legacy :-webkit-autofill). We pair it with a keyframe animation
  // in CSS so that "became autofilled" produces an animationstart event.
  const onAnimationStart = (e: AnimationEvent) => {
    if (e.animationName !== "csf-autofill-start") return;
    const el = e.target as HTMLInputElement;
    if (el.name && el.value !== getState()[el.name]) setField(el.name, el.value);
  };

  // Layer 2: a catch-all 'change' listener on the form. Some fills skip
  // 'input' but still fire 'change' on blur; delegation covers fields that
  // mount later (e.g. a second wizard step).
  const onChange = (e: Event) => {
    const el = e.target as HTMLInputElement;
    if (el.name && el.value !== getState()[el.name]) setField(el.name, el.value);
  };

  // Layer 3: reconcile everything just before validation on submit. This is
  // the only layer that catches Chrome's "readable after first interaction"
  // case, because the submit click IS the first interaction.
  // Capture phase so it runs before the framework's own submit handler.
  const onSubmitCapture = () => {
    const state = getState();
    for (const el of Array.from(form.elements) as HTMLInputElement[]) {
      if (!el.name || el.type === "file") continue;
      if (el.value !== state[el.name]) setField(el.name, el.value);
    }
  };

  form.addEventListener("animationstart", onAnimationStart, true);
  form.addEventListener("change", onChange);
  form.addEventListener("submit", onSubmitCapture, true);
  return () => {
    form.removeEventListener("animationstart", onAnimationStart, true);
    form.removeEventListener("change", onChange);
    form.removeEventListener("submit", onSubmitCapture, true);
  };
}
/* The animation does nothing visible; it exists so the browser tells us
   (via animationstart) the moment a field enters the autofilled state. */
@keyframes csf-autofill-start { from { outline-color: inherit; } to { outline-color: inherit; } }
input:autofill,
input:-webkit-autofill { animation: csf-autofill-start 1ms; }

In React the submit-time layer needs one extra step: setField enqueues a state update, so your submit handler must validate the values it just read from the DOM rather than the state snapshot captured by its closure. Build the payload from new FormData(form) merged over state, and validate that.


Step-by-step walkthrough

  1. Give every field a real name and the right autocomplete token. Autofill keys off autocomplete="email", "current-password", "postal-code" and friends; the reconciliation code keys off name. Both must be present, and the tokens are catalogued in autocomplete tokens for autofill-friendly forms.
  2. Add the :autofill animation hook in CSS. It converts a paint-state change into an event you can listen for, which is the only way to learn about fills that dispatch nothing.
  3. Listen for change on the form, not per field. Delegation catches fields rendered after the listener was attached and those inside a later wizard step.
  4. Reconcile in the capture phase of submit. Walk form.elements, compare each value with state, and write the differences before validation runs.
  5. Validate what you just reconciled. In frameworks with asynchronous state updates, build the payload from the DOM-merged values rather than a stale closure.
  6. Clear stale “required” errors after reconciliation. If an error was shown for an empty field that autofill has since filled, re-run that field’s validator so the message disappears, following the rules in revalidating after the first submit.
Which layer catches which autofill case If the browser fired an input event, the normal controlled handler already updated state. If not, but the field entered the autofill state, the animationstart listener updates it. If neither happened but the field fired change on blur, the delegated change listener updates it. Otherwise the capture-phase submit reconciliation reads the DOM and updates state before validation, which is the only path that works for Chrome's value-withheld prefill. Did the fill dispatch an input event? Normal handler yes no Did the field match :autofill? animationstart listener yes no Did change fire on blur? Delegated change listener yes no Submit-time reconciliation Capture-phase read of form.elements before validation. Always runs.

Failure modes and edge cases

1. Reading el.value on load and getting an empty string

You cannot defeat Chrome’s withholding, and you should not try (polling el.value or calling focus() on page load does not unlock it). Design so that nothing important happens before the first interaction: do not run validation on mount, and do not disable the submit button based on empty state, because the button would stay disabled over a visibly filled form.

2. Floating labels that overlap the autofilled value

A label positioned by a value ? "raised" : "resting" class stays resting while state is empty, so it sits on top of the filled text. Drive the raised state from CSS instead:

.field input:is(:focus, :not(:placeholder-shown), :autofill) + label { transform: translateY(-1.2rem) scale(0.85); }

3. React’s value tracker swallows a setter-based fill

When an extension sets input.value = "x" and then dispatches new Event("input", { bubbles: true }), React compares against its tracked value and may decide nothing changed. The submit-time layer covers this; do not reach for private React internals to “fix” it.

4. Autofill writes into fields you hid

Address autofill happily fills a hidden “company” or “address line 2” field. When you reconcile at submit, respect the rules in skipping validation for disabled and hidden fields so a hidden field’s autofilled value is neither validated nor sent unless you intend it.

5. One-time codes and autofill on iOS

SMS code autofill (autocomplete="one-time-code") inserts the whole code into one field. If you split the code across several inputs, the fill lands in the first box only. Use a single input, or distribute the pasted value as described in one-time code inputs.

What the user sees versus what state holds On screen the email and password fields are visibly filled by the browser. In the controlled state object both values are still empty strings, so a required-field check fails and the submit is blocked. After submit-time reconciliation the state matches the screen, the stale required errors are cleared and validation passes. On screen email: [email protected] password: •••••••• Looks complete to the user. In state email: "" password: "" Required checks fail; submit blocked with no visible cause. After reconcile State copied from the DOM in the submit capture phase. Stale errors re-validated and cleared.

Verification checklist


Frequently Asked Questions

Why does el.value return an empty string for a field I can see is filled?

Chrome withholds prefilled credential values from script until the user interacts with the page, to stop a hostile page harvesting saved passwords without consent. The value becomes readable after the first click or key press, and no event fires when that happens, so reconcile at submit time.

Is the :autofill animation trick still needed in current browsers?

It is less critical than it was, because most text fills now dispatch input. It still earns its place for multi-field fills and for UI that must react before submit — raising labels, clearing a “required” hint — and it costs one CSS rule and one listener.

Should I turn autofill off with autocomplete="off" to avoid all this?

No. Browsers ignore autocomplete="off" for credential fields, and disabling autofill on address or payment fields makes forms slower and harder to use, especially for people with motor or cognitive disabilities. Handle autofill; do not fight it.


Related

← Controlled vs Uncontrolled Forms