The exact problem: the server rejects an email address as already registered, the reader presses the arrow key to move the caret, and the error disappears — because the form clears errors on change without asking where the error came from.
Context and Prerequisites
This implements the lifetime rule from server error reconciliation: a local error is cleared by the next keystroke, and a server error is cleared only when the value actually differs from the one that was rejected. Doing that requires an error shape carrying an origin and the rejected value, which is why a bare Record<string, string> cannot express any of this.
Core Pattern
interface FieldError {
readonly message: string;
readonly code: string;
readonly origin: 'local' | 'server';
/** Present only for server errors: the exact value the server judged. */
readonly rejectedValue?: unknown;
}
/**
* Decide whether an error survives a change to its field.
* Local errors never survive — they will be recomputed immediately.
* Server errors survive until the value genuinely differs from the rejected one.
*/
export function survivesChange(error: FieldError, nextValue: unknown): boolean {
if (error.origin === 'local') return false;
// Normalise both sides: a trailing space or a case change in an email is not
// a new answer, and clearing on it hides a problem that still applies.
return Object.is(normalise(error.rejectedValue), normalise(nextValue));
}
export function onFieldChange(
errors: Readonly<Record<string, FieldError>>,
field: string,
nextValue: unknown,
): Record<string, FieldError> {
const current = errors[field];
if (!current) return errors as Record<string, FieldError>;
if (survivesChange(current, nextValue)) return errors as Record<string, FieldError>;
const { [field]: _dropped, ...rest } = errors;
return rest;
}
normalise is the same function the dirty tracking uses — trim, empty-to-null, type coercion — for exactly the reason described in dirty and pristine state tracking. Comparing raw strings means "[email protected] " reads as a different answer from "[email protected]", and the error clears on a change the server would judge identically.
Step-by-Step Walkthrough
-
Give every error an origin. Local errors come from the schema; server errors come from a rejected submit. Without the tag, the two behave identically and one of them is wrong.
-
Record the rejected value at reconciliation time. Take it from the payload that was sent, not from the field’s current contents, because the reader may already have typed something else.
-
Route every change through one function.
onFieldChangeis the only place errors are dropped, so the rule is applied consistently rather than re-derived in each component. -
Normalise both sides. Reuse the dirty-tracking normaliser so “differs” means the same thing everywhere in the form.
-
Re-announce a surviving error politely. A reader who edits a field and hears nothing may reasonably assume the problem is fixed. A polite live-region update saying the error still applies costs one line and prevents a wasted submit.
-
Clear on a successful re-submit, not before. The only authority on whether a server error still applies is the server.
Failure Modes and Edge Cases
1. The value returns to the rejected one
A reader types something else, then undoes it. The error was cleared on the first change and must come back on the undo — otherwise the field looks clean while holding a value the server has already refused:
// Keep dismissed server errors keyed by their rejected value so the SAME
// answer reappearing brings its error with it.
const dismissed = new Map<string, FieldError>(); // normalised value -> error
function afterChange(field: string, next: unknown, errors: Errors): Errors {
const key = String(normalise(next));
const revived = dismissed.get(`${field}:${key}`);
return revived ? { ...errors, [field]: revived } : onFieldChange(errors, field, next);
}
2. A cross-field server rule
“These dates overlap an existing booking” is attached to one field but caused by two. Changing either should clear it. Record the fields the rule read, and clear when any of them changes — attaching the rejected value of only one field means editing the other leaves a stale error.
3. The reader edits during the request
A 422 arriving for a value the reader has already replaced must not render at all. The same comparison covers it: if the current value already differs from rejectedValue, the error is stale on arrival and is discarded rather than shown.
4. Clearing on blur instead of on change
Waiting for blur means the error is still on screen while the reader types the fix, which reads as the form not noticing. Clear on change; re-run local validation on blur as usual.
5. The server error and a local error collide
A field can fail a local rule and carry a server error at once. Precedence keeps the server one visible, but if the local rule now fails, showing “already registered” for an address that is no longer a valid address is confusing. Show the local error while it applies, and restore the server one when the value becomes locally valid again — the map keeps both.
The retained-error map needs a lifetime of its own, and it is shorter than the form:
Verification Checklist
Related
- Server Error Reconciliation — where the rejected value is captured
- Mapping 422 Responses to Field Errors — attaching the error in the first place
- Dirty and Pristine State Tracking — the normaliser both comparisons share
Frequently Asked Questions
Why not just clear every error on change and re-submit to find out?
Because the reader pays for it. Clearing on change makes ‘already registered’ vanish before it has been read, and the only way to get it back is another submit — which for a checkout means another round trip and, if the submit has side effects, another attempt at something that will fail. Keeping the error until the answer actually changes costs one comparison and turns a guessing game into a conversation.
What about a server error caused by two fields?
Record which fields the rule read, and clear when any of them changes. Attaching the rejected value of a single field means editing the other one leaves a stale error pointing at input the reader has already fixed. The rule read a tuple, so the lifetime should be keyed on the tuple — the shape is the same, just with an array of rejected values instead of one.
Should a surviving server error be re-announced when the reader edits?
Politely, yes. A reader who changes a field and hears nothing reasonably concludes the problem is resolved. A polite live-region update — the same message, re-announced — tells them it still applies without interrupting. Do not use an assertive region for this: it fires on every edit and interrupts mid-word, which is worse than saying nothing.