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.

Which edits actually change the answer The field holds an address the server rejected as already registered. Moving the caret with an arrow key produces no change at all and must not clear the error. Deleting and retyping the same characters produces a value identical after normalisation, so the error still applies. Changing the case of the domain also normalises to the same address, and the server would reject it identically, so the error survives. Typing a genuinely different address is the only edit that makes the server's judgement obsolete, and it is the only one that clears the error. The reader does this Normalised value The error… presses an arrow key unchanged stays — nothing was answered retypes the same text identical stays — same answer changes the case identical after normalising stays — same address Only a genuinely different value clears it: the server judged an answer, and the answer has to change for the judgement to lapse. Clearing on the first keystroke turns "already registered" into a message that vanishes before it can be read. Clearing on nothing at all is the other failure: the reader fixes the address and the old error is still sitting there.

Step-by-Step Walkthrough

  1. 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.

  2. 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.

  3. Route every change through one function. onFieldChange is the only place errors are dropped, so the rule is applied consistently rather than re-derived in each component.

  4. Normalise both sides. Reuse the dirty-tracking normaliser so “differs” means the same thing everywhere in the form.

  5. 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.

  6. 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.

Dismissed is not the same as discarded The server rejects the address and the error is shown. The reader types a different address, so the error is dismissed from view but kept in a map keyed by the value that was rejected. The reader then undoes the edit, restoring the original address, and the error is revived — because nothing has changed the server's judgement. Finally the reader types a genuinely new address, the error is dismissed again, and a successful submit discards the retained entry for good. rejected "already registered" shown on the field reader edits dismissed from view, kept in the map reader undoes it same value returns, so the error revives new value dismissed; cleared for good on success Why revival matters more than it sounds Undo is a reflex. Without revival, a reader who tries an alternative and changes their mind is left with a clean-looking field holding a value the server has already refused, and discovers it only on the next submit. Key the retained entry by field and normalised value, and drop the whole map once a submit succeeds.

The retained-error map needs a lifetime of its own, and it is shorter than the form:

When the retained map is written, read and dropped The map is written when a server error is dismissed by an edit, keyed by the field and the normalised value that was rejected. It is read on every subsequent change to that field, so returning to the rejected value revives the error rather than leaving a clean-looking field holding a refused answer. It is dropped entirely when a submit succeeds, because every judgement it holds was about a payload that has now been superseded. Keeping it beyond that point means a reader who edits the same record again is shown a rejection that no longer applies. When the retained map is written, read and dropped written on dismissal, keyed by field and rejected value read on every later change to that field dropped on a successful submit — every judgement is stale Not on failure: a failed submit means those judgements are still exactly as current as they were.

Verification Checklist


Related

Server Error Reconciliation

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.