Client-side validation is a courtesy. The server’s rejection is the fact. Every form eventually has to render a failure it could not have predicted — an address that is already registered, a coupon that expired between page load and submit, a business rule that only the server can evaluate — and the machinery for that is different from the machinery for “this field is empty”.

Problem Statement

The specific sub-problem is attribution over time. A local validation error is a pure function of the current value, so it is always current: change the value and it is recomputed. A server error is a snapshot of a judgement made about one particular payload at one particular moment. It cannot be recomputed locally, it may be stale the instant the reader types, and there is no rule that says when it stops being true.

That difference produces four requirements that a form built only for local validation does not meet:

  • Every server error needs an origin and a subject. Which field, and what value was rejected. Without the value, there is no way to decide when the error stops applying.
  • Server errors must not be cleared by the same trigger that clears local ones. A keystroke invalidates a local rule; it does not invalidate a server judgement.
  • Some server errors belong to no field at all. “Your session expired” and “this order can no longer be modified” have no input to sit beside.
  • Rejections are not all validation. A 500 and a 422 both fail the submit, and treating them the same tells the reader their input was wrong when the truth is that your service was.

State Machine Specification

The submission’s outcome fans out into four handling paths, and conflating any two of them produces a recognisable bug.

Response Meaning Where it goes Retry?
2xx accepted success state; draft discarded n/a
422 with field paths per-field validation failure mapped onto the fields named after an edit
409 the record changed underneath conflict resolution, not validation after reconciling
4xx without field paths form-level refusal — auth, policy, rate limit the form-level summary depends
5xx, network, timeout your service failed, not their input a retryable banner; fields untouched yes, unchanged

The last row is the one most often collapsed into the second. Marking fields invalid because a gateway timed out blocks a submit the server would have accepted, and it tells the reader they made a mistake they did not make.

One response, four very different destinations The submit response is inspected once. A 422 carrying field paths is mapped onto the named fields, where each error records the value that was rejected. A 409 goes to conflict resolution rather than to validation, because the reader's input was not wrong — the record moved. A 4xx with no field paths, such as an authorisation or policy refusal, goes to the form-level summary, since there is no field to attach it to. A 5xx, a network failure or a timeout produces a retryable banner and leaves every field exactly as it was, because nothing about the reader's input has been judged. submit response inspected once 422 with field paths their input was rejected → onto the named fields, with the rejected value 409 the record moved → conflict resolution, not validation 4xx, no field paths auth, policy, rate limit → the form-level summary 5xx, network, timeout your service failed → retryable banner; fields untouched

Core Implementation

The reconciler is a pure function from a response to a set of state changes. Keeping it pure is what makes the five rows above testable against captured payloads rather than against a running server.

interface ServerFieldError {
  readonly field: string;          // canonical dotted path, matching the rendered name
  readonly message: string;        // shown as-is; the server owns this wording
  readonly code: string;           // stable identifier for analytics and for tests
  /** The exact value that was rejected. This is what makes clearing possible. */
  readonly rejectedValue: unknown;
  readonly origin: 'server';
}

type SubmitOutcome =
  | { kind: 'accepted'; record: unknown }
  | { kind: 'field-errors'; errors: ServerFieldError[] }
  | { kind: 'form-error'; message: string; code: string; retryable: boolean }
  | { kind: 'conflict'; remoteVersion: string }
  | { kind: 'unavailable'; retryAfterMs: number | null };

export function reconcile(res: Response, body: unknown, sent: Record<string, unknown>): SubmitOutcome {
  if (res.ok) return { kind: 'accepted', record: body };

  if (res.status === 409) {
    return { kind: 'conflict', remoteVersion: String((body as any)?.version ?? '') };
  }

  if (res.status === 422 && Array.isArray((body as any)?.errors)) {
    const errors: ServerFieldError[] = [];
    for (const raw of (body as any).errors as any[]) {
      const field = toCanonicalPath(raw.pointer ?? raw.field ?? '');
      // An error we cannot attach to a rendered field must NOT be dropped —
      // it becomes a form-level message instead, or the submit fails silently.
      if (!field) continue;
      errors.push({
        field,
        message: String(raw.detail ?? raw.message ?? 'This value was not accepted'),
        code: String(raw.code ?? 'server_rejected'),
        // Capture what was sent, not what is in the field now: the reader may
        // already have typed something else while the request was in flight.
        rejectedValue: valueAt(sent, field),
        origin: 'server',
      });
    }
    if (errors.length > 0) return { kind: 'field-errors', errors };
  }

  if (res.status >= 500 || res.status === 408) {
    const header = res.headers.get('retry-after');
    return { kind: 'unavailable', retryAfterMs: header ? Number(header) * 1000 : null };
  }

  return {
    kind: 'form-error',
    message: String((body as any)?.detail ?? 'We could not submit this form.'),
    code: String((body as any)?.code ?? `http_${res.status}`),
    retryable: res.status === 429,
  };
}

rejectedValue is the field that makes everything downstream possible. It is captured from the payload that was sent, not from the form’s current values, because a reader can keep typing while a request is in flight — and an error attributed to a value they have already replaced is an error that should never have been shown.

Integration Guidance

Server errors join the same FieldErrorMap that local validation produces, which means everything in error state mapping patterns applies to rendering them. The difference is precedence and lifetime: a server error outranks a local one for the same field, because it was produced with information the client does not have, and it survives triggers that clear local errors.

Path translation is the other integration point, and it is where most of the practical difficulty lives. Servers describe fields in their own vocabulary — JSON Pointer, snake_case, a nested resource path — and the form describes them in the vocabulary its inputs were rendered with. One translation function, tested against captured payloads, is the whole solution; scattering replace('/', '.') calls through components is how a renamed API field becomes a silently unrendered error.

The submission lifecycle from submission state and optimistic updates supplies the states these outcomes drive. field-errors and form-error both move the submission to failed while keeping the idempotency key, so a corrected retry is still the same logical request.

Two kinds of error that only look alike when rendered A local error is produced by a rule in the client's own schema, can be recomputed at any moment, is cleared by the next keystroke, loses precedence to a server error for the same field, and cannot survive a reload because it is derived. A server error is produced by information only the server has, cannot be recomputed locally, is cleared only when the value differs from the one that was rejected, outranks a local error for the same field, and can survive a reload if it is persisted with the draft. Rendering them identically is correct; managing them identically is not. Property Local error Server error produced by a rule you own data only the server has recomputable any time never, locally cleared by the next keystroke a value that differs from the rejected one precedence loses wins — it saw more Render them the same way; manage them differently. A single "error" type with no origin field cannot express any of this.

Edge Cases and Failure Modes

The server names a field the form does not render. A rule about a derived or server-only field produces an error with nowhere to go. Never drop it — promote it to a form-level message so the reader at least learns why the submit failed.

The reader edits during the request. By the time the 422 arrives, the field may hold something else. Because the error records rejectedValue, the comparison is trivial: if the current value already differs, the error is stale and must not be rendered at all.

Two errors for one field. A server can legitimately return several issues for the same input. Render the first and keep the rest, exactly as error state mapping patterns prescribes — a field showing four messages at once is a field nobody reads.

Repeated groups and indices. /addresses/1/postcode refers to a position, and positions move. If the reader deletes a row between submit and response, the error now points at a different row. Key rows by a stable id and translate the index through it, or re-validate after any structural edit.

A localised message you cannot use. If the server’s wording is not in the reader’s language, or is written for an operator rather than a reader, map the code to your own copy and treat message as a fallback. This is exactly why code is required rather than optional.

The submission state each outcome leaves behind is worth writing down too, because it decides what the reader can do next:

What each outcome leaves the form in An accepted submit moves to succeeded, discards the idempotency key and the snapshot, and clears the draft. A field-scoped 422 moves to failed while keeping the key, so a corrected resubmit is still the same logical request, and it re-enables the submit control immediately because the reader is what the form is waiting for. A form-level refusal does the same but has no field to focus, so focus goes to the summary. An unavailable service keeps the key and retries automatically before handing the reader a manual retry. Outcome Submission state Key kept? Reader can accepted succeeded no — discarded move on 422, field-scoped failed yes fix and resubmit 4xx, form-level failed yes read the summary 5xx or network failed, retryable yes wait, or retry now Only the first row discards the key. Every failure keeps it, which is what makes any retry safe.

Troubleshooting Reference

Symptom Diagnostic step Recovery
Submit fails with nothing on screen Log how many mapped errors matched a rendered field Promote unmatched errors to the form-level summary
A server error vanishes on the first keystroke Check whether clearing branches on origin Clear server errors only when the value differs from rejectedValue
Fields go red after a gateway timeout Check the branch order — is >= 500 reached before the 422 branch? Route 5xx to a retryable banner and leave fields alone
An error lands on the wrong row of a repeated group Compare the path index against the rendered row’s id Key rows by id; re-validate after structural edits
The message reads like a stack trace Check whether code is being mapped to house copy Map codes to your own wording; keep message as fallback

Who owns the wording

A rejection arrives with two things that could be shown to the reader: a machine-readable code and a human-readable message. Which one you render decides who owns the wording of your form, and the answer should almost always be that you do.

Server messages are written for whoever reads them first, which is usually an engineer looking at a log or a client developer reading API documentation. They leak internal vocabulary — constraint names, table names, the word validation — they are frequently untranslated, and they change without notice because nobody considers a message string a breaking change. Rendering them verbatim means your form’s copy standards apply to every string except the ones the reader sees at the moment they are most stuck.

Mapping codes to copy you own inverts that. The server’s code becomes the stable contract, your copy table becomes the wording, and an unrecognised code falls back to the server’s message so a rule shipped by the API today still surfaces something rather than nothing:

// The code is the contract; the message is the fallback. An unmapped code still
// renders — badly worded is strictly better than invisible.
const COPY: Record<string, string> = {
  email_taken:      'That address is already registered — sign in instead?',
  coupon_expired:   'That code has expired. Remove it to continue.',
  address_unserviced: 'We do not deliver to that post code yet.',
};

const messageFor = (e: ServerFieldError) => COPY[e.code] ?? e.message;

Two things follow from this that are worth doing deliberately. First, log every unmapped code, because a steady stream of one code means a rule the reader is being told about in the API’s voice rather than yours. Second, treat the code list as part of the API contract in review: a new rejection reason that ships without a code is a rejection reason your form cannot speak about properly.

Testing and QA Hooks

Capture real payloads and test the reconciler against them. A fixture directory of recorded 422, 409, 429 and 500 responses turns the whole surface into fast unit tests, and it catches the case that integration tests never reach: an API that changes its error shape without changing its status code.

it('does not render a server error the reader has already edited past', () => {
  const sent = { email: '[email protected]' };
  const out = reconcile(res422, body, sent);
  const err = (out as any).errors[0];
  expect(isStale(err, { email: '[email protected]' })).toBe(true);   // already changed
  expect(isStale(err, { email: '[email protected]' })).toBe(false);  // unchanged
});

For end-to-end coverage, assert on data-error-origin="server" rather than on message text, and add one test per row of the response table above. The 5xx row is the one worth being strict about: assert that no field carries aria-invalid after a simulated gateway failure.

Common Pitfalls

  • A single error: string per field. With no origin, no code and no rejected value, none of the behaviour on this page is expressible.
  • Clearing every error on change. It makes server errors disappear on a stray keypress while the problem remains.
  • Rendering 5xx as validation. It blames the reader for your outage and blocks a submit that would have succeeded.
  • Dropping unmapped errors. A failed submit with no visible reason is indistinguishable from a broken form.
  • Trusting the message. Server wording is written for whoever wrote the API. Map codes to copy you control.

Related

Validation Logic & Schema Integration

Frequently Asked Questions

Should the server error message be shown verbatim?

Only as a fallback. Server wording is usually written for whoever is reading logs, may not be localised, and can leak internal vocabulary — ‘constraint uq_users_email violated’ is accurate and useless. Map the stable code to copy you own, and fall back to the server’s message only when the code is unrecognised, so a new rule shipped by the API still surfaces something rather than nothing.

How do I attach a server error to the right field when paths do not match?

One translation function, tested against captured payloads. It converts the server’s vocabulary — JSON Pointer, snake_case, a nested resource path — into the canonical dotted name the form used when it rendered the input. Keeping it in one place means a renamed API field is a single failing test rather than an error that silently stops rendering, and it gives you a natural home for the fallback that promotes unmatched paths to the form-level summary.

Should server errors be persisted with a draft?

Generally no. A server error is a judgement about a specific payload at a specific moment, and by the time a draft is restored it may be hours out of date — the address that was taken may now be free. Persist the answers, let the next submit re-earn any rejection. The exception is a form-level error that is still obviously relevant, such as an account being suspended, and even that is better re-fetched than restored.

What is the right retry behaviour after a 422?

None automatic. A 422 means the payload was understood and rejected, so retrying the same payload gets the same answer — the retry has to wait for the reader to change something. Keep the idempotency key so that when they do resubmit it is still the same logical request, and re-enable the submit control immediately rather than after a timer, because the reader is the thing you are waiting for.