When a field is checked by native constraints, a schema, an async uniqueness check and the server, the error shown is whichever source wrote last — so “Enter a valid email” flickers into “Email already registered” and back, or a stale server error survives after the user fixes the value.
The mapping from errors to components is covered in error state mapping patterns. This page is about the step before mapping: several independent validators each have an opinion about the same field, and the form needs one answer. The fix is to store errors per source and derive the displayed message with a fixed precedence.
Context and prerequisites
A signup email field commonly has four validators:
- Constraint — the browser’s
type="email"andrequiredvia the Constraint Validation API. - Schema — Zod or similar: format, length, blocked domains.
- Async — “is this address already registered?” against an endpoint.
- Server — the submit response’s field errors, which can disagree with all of the above.
Each runs on a different trigger and finishes at a different time. If they write into one errors.email slot, the slot holds the last writer’s opinion, and clearing is ambiguous: when the async check passes, should it clear a schema error it knows nothing about? Keep the sources separate and the question disappears.
The core pattern: per-source slots and a precedence function
export type Source = "constraint" | "schema" | "async" | "server";
export interface SourcedError { message: string; code: string; forValue: string }
// errors[path][source] — each validator owns exactly one slot per field.
export type ErrorTable = Record<string, Partial<Record<Source, SourcedError>>>;
// Earlier in the list wins. Local format problems outrank remote opinions:
// "already registered" is meaningless for a value that is not an email.
const PRECEDENCE: Source[] = ["constraint", "schema", "server", "async"];
export function setSourceError(t: ErrorTable, path: string, source: Source, err: SourcedError | null): ErrorTable {
const row = { ...(t[path] ?? {}) };
if (err) row[source] = err; else delete row[source];
return { ...t, [path]: row };
}
export function displayedError(t: ErrorTable, path: string, currentValue: string): SourcedError | null {
const row = t[path] ?? {};
for (const source of PRECEDENCE) {
const e = row[source];
// An error computed for a different value is stale: never show it.
// This matters most for async and server results that arrive late.
if (e && e.forValue === currentValue) return e;
}
return null;
}
// When the value changes, remote opinions about the OLD value are void.
export function onValueChange(t: ErrorTable, path: string): ErrorTable {
const { server, async: _async, ...local } = t[path] ?? {};
return { ...t, [path]: local }; // local validators will overwrite their own slots
}
Each validator calls setSourceError for its own source only, stamping the value it validated in forValue. The component renders displayedError(table, path, value) and nothing else.
Step-by-step walkthrough
- Give each validator its own slot. A validator can set or clear its slot, never another’s. Clearing becomes unambiguous: passing the async check removes only the async error.
- Stamp every error with the value it judged.
forValuelets the display function discard late results for values the user has already changed, which guards against races even if cancellation is missed. - Fix a precedence order. Local, deterministic checks first; server next; async last. The ordering encodes “fix the format before worrying about availability”.
- Drop remote slots on value change. Server and async errors are about a specific submitted or checked value. The moment the value changes they are void; clearing server errors when a field changes goes deeper on the server side.
- Derive
aria-invalidfrom the displayed error. If nothing displays, the field is not invalid, whatever lower-precedence slots hold. - Show one message per field. Multiple simultaneous messages for one field are noise; the precedence already picked the most useful one.
Failure modes and edge cases
1. Native and schema messages disagree in wording
The browser’s validationMessage is localised by the browser and worded differently in each one. If you use the Constraint Validation API for its checks, supply your own message via setCustomValidity so the constraint and schema slots speak with one voice, as in using the Constraint Validation API with custom form state.
2. Server says valid, client says invalid
After deploying a stricter schema, the server may accept values the client now rejects, or the reverse. Precedence picks one message, but you should also log disagreements; sharing one schema as described in sharing one Zod schema between client and server removes the class of bug.
3. Pending async state hides behind a stale error
While the async check runs, show a pending indicator rather than the last result. With forValue stamping, the stale result is already hidden; the pending state is covered in accessible pending state for async validation.
4. Flicker between sources
When the user fixes the format, the schema error clears and the async result for the same value may arrive 300 ms later. Showing nothing in between is correct; animating the message out and back in is the flicker users notice. Keep the message container in place and swap its text without an exit animation.
5. Form-scope errors in a field table
Server errors that do not map to a field belong in form scope, not in a slot under an invented path. Keep the table for fields, and model the rest as described in modelling form-level vs field-level errors.
Verification checklist
Frequently Asked Questions
Why rank server errors above async ones?
A server error is the result of an actual submit and is authoritative for that value; an async pre-check is advisory and can be out of date by the time the user submits. If both disagree about the same value, the server’s answer is the one that will keep happening.
Should I ever show more than one message for a field?
Password rules are the usual exception: a checklist of requirements the user can satisfy one by one is more helpful than a single message. That is a different UI — a requirements list with its own status — rather than several errors competing for one slot.
Does the forValue stamp replace AbortController?
No, it complements it. Cancelling stale requests saves network and server work; the stamp guarantees correctness even when a response slips through, for example because a server ignored the abort or a cached promise resolved.