Psychology Perception
Cognitive Load and Error Recovery UX
Reading level
Four errors at the top, zero idea where to fix them
A checkout form showed all validation errors in a red summary box at the top of the page on submit. The box listed: "Email is invalid. Phone number is required. Card number is incorrect format. Billing address required." The user who submitted the form had just filled in 12 fields. Their working memory was empty — everything they'd typed was now "saved" in the form.
Reading the error box meant re-loading 4 new facts into working memory, then scrolling down to find each of the 4 fields, then reinterpreting what they'd entered, then fixing each one. Support called it "form rage." Switching to inline errors — red text appearing immediately below the offending field — cut form-related support tickets by 60%.
Cognitive Load Theory (John Sweller, 1988) distinguishes three types of load: intrinsic (the task itself — filling in the form), extraneous (caused by poor UI design — finding and interpreting errors), and germane (learning and understanding). Error messages are extraneous load — they redirect working memory from the goal to debugging the UI. Inline errors reduce extraneous load by presenting the error adjacent to the cause.
The two distinct sub-problems in error recovery: discoverability (user notices an error exists) and actionability (user knows how to fix it). A red border fails discoverability on mobile (color alone is insufficient per WCAG 1.4.1). "Invalid email" fails actionability — it describes the problem but doesn't prescribe the fix. "Enter a valid email like name@example.com" solves both.
The inline error pattern
The pattern: validate on blur (when the user leaves a field), not on submit and not on every keystroke. Validating on submit is too late — the user has lost context. Validating on keystroke is too aggressive — it marks fields invalid before the user has had a chance to finish typing.
function EmailField() {
const [error, setError] = useState('');
const validate = (value) => {
if (!value) return 'Email is required';
if (!value.includes('@')) return 'Enter a valid email like name@example.com';
return '';
};
return (
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
onBlur={e => setError(validate(e.target.value))}
aria-invalid={!!error}
aria-describedby={error ? 'email-error' : undefined}
/>
{error && (
<p id="email-error" role="alert" className="field-error">
{error}
</p>
)}
</div>
);
}
The aria-invalid and aria-describedby attributes link the error to the field for screen readers — visual proximity isn't enough for non-visual users.
Actionable error copy follows a simple pattern: describe what's wrong + provide an example of what's right.
// ❌ Non-actionable errors — describe the violation, not the fix
'Invalid email'
'Phone number format incorrect'
'This field is required'
// ✅ Actionable errors — describe + prescribe
'Enter a valid email like name@example.com'
'Enter a 10-digit phone number like 9876543210'
'Your name is required to complete your order'
// Pattern: [what to do] + [example/reason]
The reason/example component is the key difference — users know what they did wrong (the form rejected their input) but need to know exactly what "correct" looks like.
The API contract matters for error recovery quality. A 422 response with { field: 'email', code: 'ALREADY_EXISTS', message: 'An account with this email already exists. Try signing in instead.' } enables a precise, actionable inline error. A generic 500 with no field mapping forces a top-level "Something went wrong" — the API design failure pays its cost in UX.
When you must show a top-level error (generic server failure), anchor focus to the error and include a recovery path: "We couldn't save your changes. Try again, or contact support if this continues." A top-level error with no recovery path is a dead end.
The checkout that just said "Error 422"
Priya spent ten minutes filling out a checkout form — name, address, card number, everything. She hit "Place Order." The page reloaded and a red box at the top read: Error 422. That's all. No field highlighted. No explanation. No suggestion for what to do next.
She scrolled up and down looking for something wrong. Nothing looked wrong to her. She tried submitting again. Same message. She closed the tab and bought from a competitor. The session recording showed 23 other users doing exactly the same thing that week.
The technical cause: a server-side 422 Unprocessable Entity fired because the card number failed a Luhn check. But the frontend swallowed the API error body and displayed only the HTTP status code. No field was marked invalid, no message was rendered inline, and no action was offered. The error was correct at the API layer — the failure was purely in the UI's error-handling contract.
In production analytics, "Error 422" checkout abandonment had been logged as "payment failure" — masking the UX root cause in the data. The real failure rate was 18% of checkout attempts. Because no error-specific funnel existed, the team had spent two quarters investigating payment provider issues instead of the display layer. The systemic lesson: vague error messages corrupt your analytics as much as they frustrate your users.
The message that showed exactly what to do
After the redesign, the same card failure scenario showed a message directly below the card number field: "Your card was declined. Try a different card or contact your bank." Below the message, a button: "Try a different card." The field was highlighted with a red border and an error icon — not just color alone.
Priya's scenario now resolved in under 30 seconds. She saw the message, understood what happened, switched to her other card, and completed the purchase. Support tickets for checkout errors dropped 60% in the first month.
The fix involved two changes: (1) the API contract was updated to return structured errors — { field: "cardNumber", code: "CARD_DECLINED", message: "Your card was declined. Try a different card or contact your bank." }; (2) the frontend error handler mapped field codes to inline error placement with ARIA attributes. Neither change alone would have worked — the UX fix required both the API signal and the rendering logic.
Post-fix measurement: median error-to-resubmit time fell from 94 seconds to 18 seconds. The funnel now had an "error recovery" step tracked separately from "payment failure." The analytics improvement was as valuable as the UX improvement — teams could now distinguish card-declined-recovered from card-declined-abandoned, enabling targeted recovery flows like "try saved card" suggestions on repeat declines.
Pattern at a glance
Annotated example: form error message pattern
❌ VAGUE ERROR
Error 422
User sees a code, not a cause or a fix
✅ ACTIONABLE ERROR
⚠ Your card was declined. Try a different card or contact your bank.
Error inline, cause clear, action offered
Try it: summary errors vs inline errors
Fill out both forms incompletely and hit submit. The "Summary" mode shows all errors at the top in a red box. The "Inline" mode shows errors directly below the offending field. Notice how much easier it is to find and fix errors in the inline version.
The demo measures time-to-successful-resubmit from the first failed submit attempt. Inline errors are typically 55–70% faster on recovery — users don't need to context-switch between the error list and the fields.
Pay attention to the error copy in both modes. The summary mode uses short labels ("Invalid email"). The inline mode uses actionable copy with examples. Even if both were inline, the actionable copy would be faster — discoverability and actionability are separate dimensions.
Showing: Inline — errors on blur
Implementing accessible inline error messages
The minimal pattern: a field wrapper that shows an error message below the input on blur, with the error text linked to the input via ARIA.
function Field({ id, label, validate }) {
const [error, setError] = React.useState('');
return (
<div className="field">
<label htmlFor={id}>{label}</label>
<input
id={id}
aria-invalid={!!error}
aria-describedby={error ? `${id}-error` : undefined}
onBlur={e => setError(validate(e.target.value))}
/>
{error && (
<p id={`${id}-error`} role="alert" className="field-error">
{error}
</p>
)}
</div>
);
}
Key pitfall: omitting role="alert" means screen readers won't announce the error when it appears. Without it, the error text exists in the DOM but is silent to assistive technology.
In React Hook Form, wire inline errors through the errors object from useFormState and display them in a consistent <FieldError> component. Four implementation rules:
- Validate on blur, not change. Use
mode: 'onBlur'inuseForm.mode: 'onChange'marks fields invalid before the user finishes typing. - Revalidate on change after first blur. Use
reValidateMode: 'onChange'so the error clears as soon as the user fixes it — don't wait for another blur. - Keep error IDs stable. Use
id={`${fieldName}-error`}consistently —aria-describedbymust match the exact error element ID. - Server errors need manual setError. Call
setError('cardNumber', { message: data.message })from your API error handler — React Hook Form doesn't automatically handle server-returned field errors.
const { register, formState: { errors }, setError } = useForm({
mode: 'onBlur',
reValidateMode: 'onChange',
});
// On API error response:
// setError('cardNumber', { message: apiError.message });
At design system scale, error messages need a typed contract between API and UI. The API should return a structured error schema: { errors: [{ field: string, code: string, message: string }] }. The UI maps field to a form field name and renders message inline. This contract prevents the "something went wrong" fallback from being the only error state. Enforce the contract with a shared TypeScript type in a monorepo package, and write a Zod schema for runtime validation at the API boundary. For lint tooling, add an ESLint rule (or Biome custom rule) that warns when aria-describedby is set without a corresponding element ID — this catches the most common ARIA wiring mistake at authoring time.
References
Remember
Key takeaways
-
Validate on blur, not on submit and not on every keystroke. Inline errors that appear when you leave a field are the least disruptive and easiest to act on.Actionable error copy: describe what to do + provide an example. "Enter a valid email like name@example.com" solves both discoverability and actionability. "Invalid email" solves neither.Link the error to the field with aria-invalid and aria-describedby — visual proximity works for sighted users but not for screen reader users. Both attributes are required for accessible error recovery.
-
Error messages that don't tell you how to fix the problem just add frustration on top of frustration. Every error message should answer "what should I type instead?"API error contracts matter. A 422 with field + code + actionable message enables precise inline errors. A generic 500 forces a top-level "Something went wrong" — the UX cost is paid by the user but the root cause is in the API design.Error recovery has two sub-problems: discoverability (user notices an error) and actionability (user knows how to fix it). Inline placement solves discoverability; actionable copy solves actionability. Both are required for fast recovery.
Keep going
Finish this takeaway, then continue the track — Casey saved your spot locally.
Sign in with email to sync progress across devices (beta).
Inside the Casebook
New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.
No spam. Unsubscribe anytime. Emails sent via Buttondown.
RSS feed