Map server-side validation to summary + inline messages

Map server-side validation to summary + inline messages

calendar_today ago • schedule4 min read
— Originally published at chriseugenerodriguez.com

Prerequisites

  • A form rendering layer that can: attach aria-describedby to inputs, render a top-of-page summary with linked list items, and set focus programmatically when desired.
  • Server-side validation that can return structured error data (field name + message) and non-field messages.

Implementation decision
Return a deterministic, structured payload: an array of objects for field errors ({ name, message }) and a separate representation for non-field errors. On the client, map each field error to a short inline message element with a deterministic id (for example, ${fieldId}-error) and include that id in the input’s aria-describedby. Build a summary object (heading + ordered items) that lists field-label + message in document order, and render it in a container with a live role (role=alert by default). When deciding focus: if there is at least one field error, move focus to the first errored input; if there are only non-field errors, announce the summary without stealing focus.

Expected result

  • Assistive tech users receive a clear announcement of the new error summary and can use the linked items to navigate. When focus lands on a control, the label plus the inline message are read because of aria-describedby. Partial repairs clear only the repaired field’s inline metadata; remaining errors continue to appear in the summary until the server removes them.

Limitation
This pattern assumes the server returns only validation metadata and not broad value rewrites. If the server rewrites values, the app must explicitly describe changes to avoid confusing users. Also, if your app has aggressive client-side live validation, you must throttle live announcements to prevent repeated, disruptive alerts.

Working example

// Exports a small, deterministic model of applying server-side validation results
// to an accessible form state. This is a pure, side-effect-free implementation
// suitable for unit testing. It is NOT a browser DOM manipulation library; it
// models the minimal pieces needed for accessibility decisions (ids, labels,
// inline messages, aria-describedby relationships, the summary, and focus
// recommendation).

// FormState: {
//   fields: { [id]: { id, name, label, value, errorId?: string, errorMessage?: string } },
//   prevFocusedId?: string // id that had focus before submit (optional)
// }
// Server error payloads are expected as arrays of:
// { name: <field name>, message: <string> } for field errors
// and/or { type: 'non-field', message: <string> } for global errors.

export function makeEmptyFormState(fields) {
  // fields: array of { id, name, label, value }
  const map = Object.fromEntries(fields.map(f => [f.id, { ...f }]));
  return { fields: map };
}

export function applyServerErrors(formState, serverErrors, options = {}) {
  // options:
  //  - focusFirstError: true|false (default true) : whether to set focus to the first field error
  //  - liveRegionRole: 'alert'|'polite' (default 'alert') : how summary should be announced
  // Returns new state object: { formState, summary, liveMessage, focusTargetId }

  const focusFirstError = options.focusFirstError !== false;
  const liveRegionRole = options.liveRegionRole || 'alert';

  // clone formState
  const fs = { fields: {} };
  for (const id of Object.keys(formState.fields)) {
    fs.fields[id] = { ...formState.fields[id] };
    // clear previous inline errors (server has new canonical list)
    delete fs.fields[id].errorMessage;
    delete fs.fields[id].errorId;
  }

  const fieldErrors = [];
  const nonFieldErrors = [];

  for (const e of serverErrors) {
    if (e && e.type === 'non-field') nonFieldErrors.push(e.message);
    else if (e && e.name && typeof e.message === 'string') fieldErrors.push(e);
    else if (e && !e.name && typeof e.message === 'string') nonFieldErrors.push(e.message);
    // ignore malformed entries silently (could be logged in real app)
  }

  // Map field errors to fields by name. If multiple errors target same name,
  // concatenate messages (server could choose first only, but concatenation is deterministic here).
  const byFieldName = {};
  for (const fe of fieldErrors) {
    (byFieldName[fe.name] = byFieldName[fe.name] || []).push(fe.message);
  }

  // Assign inline error ids and messages
  for (const id of Object.keys(fs.fields)) {
    const f = fs.fields[id];
    const msgs = byFieldName[f.name];
    if (msgs && msgs.length) {
      f.errorId = `${id}-error`; // deterministic id for aria-describedby
      f.errorMessage = msgs.join(' ');
    }
  }

  // Build summary: list of { fieldId?, message, linkId? }
  const summaryItems = [];
  // Field errors in document order (fields keys order)
  for (const id of Object.keys(fs.fields)) {
    const f = fs.fields[id];
    if (f.errorMessage) {
      summaryItems.push({ fieldId: id, message: `${f.label}: ${f.errorMessage}` });
    }
  }
  // Non-field errors appended
  for (const m of nonFieldErrors) summaryItems.push({ message: m });

  const summary = summaryItems.length
    ? { heading: 'There was a problem with your submission', items: summaryItems }
    : null;

  // Live region message: concise summary text (used for screen reader announcement)
  const liveMessage = summary ? `${summary.heading}. ${summary.items.length} problem(s)` : null;

  // Focus decision
  let focusTargetId = null;
  if (summary && focusFirstError) {
    // If there is at least one field error, focus first field with an error.
    const firstFieldError = summary.items.find(i => i.fieldId);
    if (firstFieldError) focusTargetId = firstFieldError.fieldId;
    else focusTargetId = null; // summary-only errors should not move focus to a form control
  } else if (summary && !focusFirstError) {
    focusTargetId = null; // policy: announce summary only (no focus jump)
  }

  // Attach aria-describedby linking: for each field with errorId, include that id in a
  // returned mapping so an applicator can set input.setAttribute('aria-describedby', errorId).
  const describedBy = {};
  for (const id of Object.keys(fs.fields)) {
    const f = fs.fields[id];
    if (f.errorId) describedBy[id] = f.errorId;
  }

  return {
    formState: fs,
    summary,
    liveRegion: summary ? { role: liveRegionRole, message: liveMessage } : null,
    focusTargetId,
    describedBy
  };
}

export function repairField(formState, fieldId, newValue) {
  // Simulates user repairing one field locally and clears its server error only.
  // Does NOT re-run server validation. Deterministic: remove errorMessage+errorId for that field.
  const fs = { fields: {} };
  for (const id of Object.keys(formState.fields)) {
    fs.fields[id] = { ...formState.fields[id] };
  }
  if (!fs.fields[fieldId]) throw new Error('unknown field id');
  fs.fields[fieldId].value = newValue;
  delete fs.fields[fieldId].errorMessage;
  delete fs.fields[fieldId].errorId;
  return fs;
}

Read the full article: https://chriseugenerodriguez.com/blog/server-side-validation-errors-screen-readers

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Should Exact Search Matches Always Come First?

WAQAS AHMAD - Oct 30

AI Email Classifier Cost per 1,000 Messages: Real Math

floxolab - Oct 8

TTFB Won't Go Down? Server-Side Culprits Beyond the Theme

ApogeeWatcherverified - Aug 23

Split-Brain: Analyst-Grade Reasoning Without Raw Transactions on the Server

Pocket Portfolio - Apr 8

Should a Form Remember Its Validation Errors?

WAQAS AHMAD - Nov 7
chevron_left
659 Points • 30 Badges
Santa Monica, CA • chriseugenerodriguez.com
24Posts
0Comments
6Connections
10 years fe

Commenters (This Week)

4 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!