An error model that only knows about fields has nowhere to put “the start date must be before the end date”, “choose at least one contact method” or “the payment service is unavailable” — so those messages end up attached to an arbitrary input, or rendered as a toast that disappears before a screen-reader user hears it.
Error state mapping patterns describes how errors flow from validators into components. This page settles the prior question: what an error belongs to. Get the scope right and the rendering, the announcement and the clearing rule all follow from it.
Context and prerequisites
Three scopes cover every error a form produces:
- Field — about one control’s value: “Enter a valid email.” Rendered next to the control, linked with
aria-describedby, and it setsaria-invalidon that control. - Group — about the relationship between several controls: “End date must be after start date”, “Choose at least one.” Rendered on the
fieldsetthat contains them, referenced from thefieldset(and optionally the most relevant control), and it clears when any member changes. - Form — about the submission as a whole, not attributable to a control: rate limits, service outages, “this record was changed by someone else”. Rendered at the top of the form, announced once, and it clears on the next submit attempt.
The common failure is collapsing group errors into one of their fields. The message then clears when the user edits the other field — the one that fixed it — does not clear, or the reader is sent to a field that is not wrong.
The core pattern: a discriminated error type and scope-aware selectors
export type FormError =
| { scope: "field"; path: string; code: string; message: string }
| { scope: "group"; group: string; members: string[]; code: string; message: string }
| { scope: "form"; code: string; message: string; retryable: boolean };
export interface ErrorState { errors: FormError[] }
// Selectors: components never filter the array themselves.
export const fieldErrors = (s: ErrorState, path: string) =>
s.errors.filter((e): e is Extract<FormError, { scope: "field" }> => e.scope === "field" && e.path === path);
export const groupErrors = (s: ErrorState, group: string) =>
s.errors.filter((e): e is Extract<FormError, { scope: "group" }> => e.scope === "group" && e.group === group);
export const formErrors = (s: ErrorState) =>
s.errors.filter((e): e is Extract<FormError, { scope: "form" }> => e.scope === "form");
// A field is invalid for aria-invalid purposes only on its OWN errors.
// Group errors mark the fieldset, not every member, so a screen reader does
// not announce "invalid" on a start date that is perfectly valid on its own.
export const isFieldInvalid = (s: ErrorState, path: string) => fieldErrors(s, path).length > 0;
// Clearing rules differ by scope. Call on every value change.
export function onFieldChanged(s: ErrorState, path: string, revalidateGroup: (g: string) => FormError[]): ErrorState {
const kept = s.errors.filter((e) => {
if (e.scope === "field") return e.path !== path; // re-run by the field validator
if (e.scope === "group") return !e.members.includes(path); // re-run below
return true; // form errors wait for submit
});
const touchedGroups = new Set(
s.errors.filter((e) => e.scope === "group" && e.members.includes(path)).map((e) => (e as any).group as string),
);
const regrouped = [...touchedGroups].flatMap(revalidateGroup);
return { errors: [...kept, ...regrouped] };
}
export const onSubmitAttempt = (s: ErrorState): ErrorState =>
({ errors: s.errors.filter((e) => e.scope !== "form") });
<fieldset aria-describedby="dates-error">
<legend>Travel dates</legend>
<label for="start">Start</label> <input id="start" name="start" type="date">
<label for="end">End</label>
<input id="end" name="end" type="date" aria-describedby="dates-error">
<p id="dates-error" class="group-error">End date must be after start date.</p>
</fieldset>
Step-by-step walkthrough
- Give every error a scope. The discriminated union makes it impossible to create a group error without naming its members, or a form error with a field path.
- Render each scope in its own slot. Field errors beside the control, group errors inside the
fieldsetbelow thelegend, form errors in a banner at the top. Components ask selectors for their scope and never search the array. - Set
aria-invalidfrom field errors only. For group errors, describe thefieldsetand the member most likely to need changing (usually the later one) with the group message. - Clear by scope. A field error is replaced when its field is revalidated; a group error is re-evaluated when any member changes; a form error stays until the next submit attempt.
- Feed all three into the summary. The error summary links field errors to their inputs, group errors to the first member, and lists form errors without a link, as in building an accessible error summary.
Failure modes and edge cases
1. Group error pinned to one field
Attaching “end must be after start” to end means that when the user fixes the problem by moving start earlier, end is never revalidated and the error stays. The group scope re-runs the rule when either changes; see validating a date range: start before end.
2. Server errors without a path
A 422 response may include messages with no field pointer, or with a pointer to a field the client does not render. Map them to form scope rather than dropping them; the rules are in mapping 422 responses to field errors.
3. Form errors shown as transient toasts
A toast that auto-dismisses after five seconds is gone before many users — and most screen-reader users — have read it. Form-scope errors are persistent until the next attempt, and live in the page, not in a floating layer.
4. Duplicate announcements
If a group error is referenced by both the fieldset and a member input, a screen reader may read it twice when focus enters the member. Reference it from the fieldset plus exactly one member, and not from all of them.
5. Form error that is really a field error
“Email already registered” from the server is about the email field, even though it arrived on submit. Scope by meaning, not by source: if the message names a field the user can fix, it is a field error.
Verification checklist
Frequently Asked Questions
Should a group error set aria-invalid on every member?
Usually not. aria-invalid tells a screen-reader user that this control’s value is wrong. For a date range, the start date may be perfectly valid; marking it invalid sends the user to change the wrong thing. Describe the fieldset with the group message and mark only the member the user most likely needs to change, if any.
Where do errors from a schema's cross-field refinement go?
Zod’s superRefine lets you choose the path for an issue. Give group issues a synthetic path such as dates that matches a group id, and map it to group scope in your adapter; do not put it on one of the real field paths unless it truly belongs to that field.
Can a form have both a form-level error and field errors at once?
Yes, and it is common: a submit can fail validation on two fields and also hit a rate limit. Show the banner and the field errors together; the summary lists the form error first because it may make fixing the fields pointless until the user retries later.