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 sets aria-invalid on that control.
  • Group — about the relationship between several controls: “End date must be after start date”, “Choose at least one.” Rendered on the fieldset that contains them, referenced from the fieldset (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.

Three scopes and where each one lives A field error such as enter a valid email renders next to its input, sets aria-invalid on it and clears when that field becomes valid. A group error such as end date must be after start date renders on the enclosing fieldset and clears when any member of the group changes and the rule passes. A form error such as the payment service is unavailable renders in a banner above the form and clears on the next submit attempt. Field "Enter a valid email." Renders beside the input; aria-invalid on it. Clears when that field is valid. Group "End date must be after start date." Renders on the fieldset. Clears when any member changes and the rule passes. Form "Payment service unavailable." Banner above the form, announced once. Clears on the next submit attempt.

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

  1. 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.
  2. Render each scope in its own slot. Field errors beside the control, group errors inside the fieldset below the legend, form errors in a banner at the top. Components ask selectors for their scope and never search the array.
  3. Set aria-invalid from field errors only. For group errors, describe the fieldset and the member most likely to need changing (usually the later one) with the group message.
  4. 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.
  5. 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.
Clearing and announcement rules by scope Field errors are re-evaluated when the same field changes, set aria-invalid on that field, are announced through the field's description, and the summary links to the field. Group errors are re-evaluated when any member changes, do not set aria-invalid on valid members, are announced through the fieldset description, and the summary links to the first member. Form errors are re-evaluated on the next submit attempt, set no aria-invalid, are announced once through an alert region, and appear in the summary without a link. Scope Re-evaluated when aria-invalid on Summary link Field that field changes the field the field Group any member changes none by default first member Form next submit attempt nothing none; text only

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.

Choosing the scope for a new error If the message is about one control the user can fix, make it a field error, even if it came from the server. If the message is about a relationship between several controls, make it a group error with those members. Otherwise, including outages, rate limits and concurrency conflicts, make it a form error with a retryable flag. Is it about one control the user can fix? Field scope yes no Does it depend on several controls together? Group scope yes no Form scope Outage, rate limit, conflict. Mark retryable.

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.


Related

← Error State Mapping Patterns