The exact problem: a reader presses submit, three fields turn red, they fix the first one — and the message stays until they leave the field, because the form is still validating on blur.

Context and Prerequisites

This implements the submit override described in choosing between blur and change validation. The rule is one sentence: after a submit attempt, every field re-validates on every change. Getting it right is mostly about where the flag lives and what resets it.

Core Pattern

interface FormValidationState {
  /** Incremented on every submit ATTEMPT, successful or not. */
  readonly submitCount: number;
  /** Per-field: has this field ever shown an error? */
  readonly shown: Readonly<Record<string, boolean>>;
}

/**
 * One predicate, used by every field. Keeping it on the FORM rather than in
 * each field component is what makes a single submit flip all of them together.
 */
export function isLive(field: string, s: FormValidationState): boolean {
  return s.submitCount > 0 || s.shown[field] === true;
}

export function reducer(state: FormValidationState, ev: Event): FormValidationState {
  switch (ev.type) {
    case 'SUBMIT_ATTEMPT':
      // Attempt, not success: a rejected submit is exactly when live feedback starts.
      return { ...state, submitCount: state.submitCount + 1 };
    case 'FIELD_ERROR_SHOWN':
      return { ...state, shown: { ...state.shown, [ev.field]: true } };
    case 'SUBMIT_SUCCEEDED':
      // The form is now a fresh one. Reset, or the next edit is live from the
      // first keystroke on a form the reader has not been told anything about.
      return { submitCount: 0, shown: {} };
    case 'FORM_RESET':
      return { submitCount: 0, shown: {} };
    default:
      return state;
  }
}

The counter rather than a boolean is deliberate. It makes “this is the second failed attempt” expressible, which the error summary uses to decide whether to re-announce, and it distinguishes “never submitted” from “submitted and succeeded and reset” without a second flag.

One flag, flipped once, reset once Before any submit, only fields the reader has already left and which failed will show messages; everything else stays quiet. The submit attempt increments the counter, which makes every field live at once — messages appear on fields the reader never focused, which is correct because they asked for judgement. While repairing, each keystroke re-validates, so messages disappear as the values become valid. A successful submit resets the counter and the per-field flags, so the next edit on what is now a fresh form starts quiet again. before submit submitCount = 0 only touched fields speak submit attempt submitCount = 1 every field is live repairing messages clear as each value passes success → reset counter and flags back to zero The bug the reset prevents Without it, a reader who submits successfully and then edits the same form again is judged from their first keystroke — on a form that has told them nothing yet. It reads as the form having become impatient. Reset on a genuine reset too: the reader asked for a blank form, and a blank form has no history. What each event does to the two pieces of state A field showing an error for the first time sets that field's shown flag and leaves the counter alone. A submit attempt increments the counter and leaves the flags alone, because the counter alone is enough to make every field live. A successful submit resets both, returning the form to its initial quiet behaviour. A form reset does the same. Nothing else writes either value, which is what keeps the predicate trustworthy. Event submitCount shown flags a field first shows an error unchanged that field set a submit attempt incremented unchanged a successful submit reset to 0 cleared form reset reset to 0 cleared Four events, two values, nothing else writes either — which is why the live predicate can be a one-line pure function.

Step-by-Step Walkthrough

  1. Keep the counter on the form. Per-field flags cannot express “one submit flipped everything”.

  2. Increment on the attempt, not on failure. The attempt is the moment the reader asked for judgement; whether it failed decides what is shown, not whether the form is live.

  3. Combine with the per-field flag. A field is live if it has spoken before or the form has been submitted.

  4. Re-validate everything on the attempt. Not just the fields the reader touched — the untouched required field is exactly the one they need told about.

  5. Reset on success and on reset. Both produce a form with no history, and both should be quiet again.

  6. Use the count for the summary heading. A changing heading is what makes a second failed submit re-announce.

Failure Modes and Edge Cases

1. Resetting on submit rather than on success

A failed submit that resets the counter puts the form back to quiet, so the reader fixes a field and gets no confirmation. Reset on the result, not on the action.

2. Live validation on an expensive rule after submit

The override makes everything live, including a remote uniqueness check. Exempt expensive rules explicitly: live for the structural rules, blur for the remote ones.

3. A field added after the submit

A conditional field revealed by an answer given after the submit attempt inherits submitCount > 0 and is live immediately — before the reader has typed in it. Judge new fields as untouched until they have been left once, even when the form is live.

4. Wizard steps

submitCount is form-wide, but a wizard’s NEXT is a per-step submit. Keep a per-step attempt count so advancing from step one does not make step three live.

5. Announcing on every repair

Every field going live means every keystroke can produce an announcement. Announce field-level results politely and debounced; the assertive channel is for the submit result only.

The four moments a reader experiences They fill the form and see nothing, because nothing has been judged. They submit and every problem appears at once, which is what they asked for. They repair, and each message disappears as its value becomes valid, which is the confirmation the repair needs. They submit again and it succeeds, at which point the form goes quiet again ready for whatever they do next. The four moments a reader experiences fill nothing is judged, nothing is said submit every problem appears — they asked for it repair messages clear as each value becomes valid succeed the form goes quiet, ready for the next edit The fourth box is the reset. Without it the next edit is judged from the first keystroke, on a form that said nothing.

Verification Checklist

Common Pitfalls

  • A boolean instead of a counter. The count is what lets the summary heading change between attempts, which is what makes a second failure re-announce. It costs the same to store.
  • Resetting on submit rather than on success. A failed submit that resets the flag puts the form back to quiet, so the reader fixes a field and gets no confirmation that it worked.
  • Making a newly revealed field live immediately. A conditional field shown after the submit attempt inherits the live state and is judged before the reader has typed in it. Treat new fields as untouched until they have been left once.
  • Using a form-wide counter in a wizard. Advancing from step one should not make step three live. Keep an attempt count per step and read the current step in the predicate.
  • Announcing every repair. Every field going live means every keystroke can produce an utterance. Announce field-level results politely and debounced; keep the assertive channel for the submit result.

Related

Form Validation Lifecycle

Frequently Asked Questions

Why a counter rather than a boolean?

Because the count is useful in three places a boolean is not: the error summary heading changes between attempts, which is what makes a second failure re-announce; analytics can tell a form failed once from one that failed five times; and ‘never submitted’ is distinguishable from ‘submitted, succeeded, and reset’. It costs the same to store.

Should the flag reset after a successful submit?

Yes, along with the per-field flags. After a success the form is effectively a new one — often literally, if it is being reused to create a second record — and a reader editing it again should get the same quiet-then-live behaviour they got the first time. Leaving it set means their next keystroke is judged on a form that has told them nothing.

Does this apply to a wizard's Next button?

The same shape, but scoped per step. Advancing from step one is a submit attempt for step one, so step one’s fields become live while step three’s stay quiet — otherwise the reader reaches step three and finds it already covered in errors about fields they have not seen. Keep an attempt count per step and use the current step’s count in the predicate.