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.
Step-by-Step Walkthrough
-
Keep the counter on the form. Per-field flags cannot express “one submit flipped everything”.
-
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.
-
Combine with the per-field flag. A field is live if it has spoken before or the form has been submitted.
-
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.
-
Reset on success and on reset. Both produce a form with no history, and both should be quiet again.
-
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.
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
- Choosing Between Blur and Change Validation — the policy this overrides
- Form Validation Lifecycle — the machine both live in
- Building an Accessible Error Summary — where the attempt count is read
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.