DEV Community

Cover image for How to keep server-side validation errors and screen readers in sync
Chris Rodriguez
Chris Rodriguez

Posted on Originally published at chriseugenerodriguez.com

How to keep server-side validation errors and screen readers in sync

When the server validates a form, the UX requirement is different from client-only validation: the server’s response is authoritative and may contain many errors. The pattern I recommend uses three cooperating pieces: a structured server payload, a page-level summary announced in a live region, and short inline messages attached to each control.

Implementation summary

  • Server payload: return an array of field errors in the shape { name: 'email', message: 'Enter a valid email.' } and a separate representation for non-field errors (quota, upstream failures). Avoid opaque banners like “Please fix the form.”
  • UI presentation: render a concise summary at the top (heading + list). Each summary item should reference the field label and link to the control. For each errored control, render a nearby message element with a deterministic id and set aria-describedby on the input so assistive tech reads the explanation when focus lands.
  • Focus policy: if there are field errors, focus the first errored control to place the user at the next editable target. If the server returns only non-field errors, announce the summary via a live region but do not move focus to a control. Avoid forcing focus when there’s nothing for the user to repair.

Expected behavior

  • Screen reader users hear the summary announcement (role=alert or similar) and can use the linked summary items to jump to specific controls.
  • When focus lands on an errored input, assistive tech reads the label plus the inline message supplied via aria-describedby, keeping context local to the edit.
  • If the user repairs one field locally, the UI clears only that field’s inline message while preserving other server errors. After the next submit, the server’s canonical list replaces the summary so stale errors do not accumulate.

Limitations and gotchas

  • Live-region noise: mixing frequent client-side validation with an always-alert summary can become noisy for screen reader users. Throttle or scope live announcements for incremental checks.
  • Server rewrites: if the server rewrites values on validation, the app must explain what changed and preserve the user’s editing context rather than silently replacing content.
  • Partial repair tests: your test plan should verify that repairing one field clears just that field’s message and that remaining errors persist until fixed.

This approach follows W3C guidance: treat server validation as structured data, present overview + detail, and keep focus movement purposeful so users—especially keyboard and screen reader users—keep their place while fixing the form.

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;
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)