We celebrate smooth flows, shiny animations, and 95+ Lighthouse scores. But users form their strongest opinions when things don’t work. That’s why error states quietly define the quality of your UI: they determine whether your product feels fragile or trustworthy.
TL;DR
- Treat errors as first-class UX scenarios, not edge cases.
- Be specific, actionable, and polite in your messaging.
- Offer recovery: retry, fallback data, or next steps.
- Make it accessible: announce errors, manage focus.
- Log, measure, and iterate.
The Mindset Shift: From Happy Path to Resilient UX
Real users hit invalid inputs, flaky networks, slow APIs, and expired sessions. If your app leaves them staring at a spinner or a vague “Something went wrong,” they will bounce—or worse, lose trust.
Good UX is not just smooth journeys; it’s graceful recoveries.
Practical Patterns in React/Next.js
1) Catch Render Crashes with Error Boundaries
// Basic ErrorBoundary (class-based) class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError() { return { hasError: true }; } render() { if (this.state.hasError) { return <FallbackUI />; } return this.props.children; } } function FallbackUI() { return ( <section role="alert" aria-live="assertive"> <h2>We hit a snag.</h2> <p>Try refreshing the page. If it keeps happening, contact support.</p> </section> ); }
Prefer a battle-tested hook/component:
// Using react-error-boundary import { ErrorBoundary } from "react-error-boundary"; function App() { return ( <ErrorBoundary FallbackComponent={FallbackUI}> <Dashboard /> </ErrorBoundary> ); }
2) Handle Async Failures with React Query
import { useQuery } from "@tanstack/react-query"; function Users() { const { data, error, isError, isLoading, refetch } = useQuery({ queryKey: ["users"], queryFn: () => fetch("/api/users").then(r => { if (!r.ok) throw new Error("Network error"); return r.json(); }), retry: 2, // limited retries staleTime: 60_000, // reduce refetch churn }); if (isLoading) return <SkeletonRows count={5} />; if (isError) { return ( <div role="alert" aria-live="assertive"> <p>Couldn’t load users. Check your connection and try again.</p> <button onClick={() => refetch()}>Retry</button> </div> ); } return <UserList items={data} />; }
3) Form Validation with Helpful, Focusable Errors
import { useForm } from "react-hook-form"; import { z } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; const schema = z.object({ email: z.string().email("Enter a valid email address"), password: z.string().min(8, "Password must be at least 8 characters"), }); type FormData = z.infer<typeof schema>; export default function SignInForm() { const { register, handleSubmit, formState: { errors } } = useForm<FormData>({ resolver: zodResolver(schema), mode: "onBlur", }); return ( <form onSubmit={handleSubmit(console.log)} noValidate> <label>Email</label> <input {...register("email")} aria-invalid={!!errors.email} aria-describedby="email-error" /> {errors.email && ( <p id="email-error" role="alert">{errors.email.message}</p> )} <label>Password</label> <input type="password" {...register("password")} aria-invalid={!!errors.password} aria-describedby="pw-error" /> {errors.password && ( <p id="pw-error" role="alert">{errors.password.message}</p> )} <button type="submit">Sign in</button> </form> ); }
4) Guard Against Forever Spinners (Timeout + Abort)
function fetchWithTimeout(url: string, ms = 10_000, options?: RequestInit) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), ms); return fetch(url, { ...options, signal: controller.signal }) .finally(() => clearTimeout(timeout)); }
Use this in data loaders and present a timeout message with next steps rather than spinning indefinitely.
UX Guidelines for Error Messaging
- Be specific: “Email already in use” beats “Invalid input.”
- Be actionable: Tell users what to do next: retry, refresh, contact support.
- Be human: Avoid blamey language. Use plain, neutral tone.
- Preserve work: Don’t nuke user input on error. Keep the form state intact.
- Prioritize recovery: Provide a clear path back to productive work.
Bad: “Error 400. Try again.”
Better: “Email already in use. Sign in instead or use a different email.”
Accessibility Essentials
- Announce errors with
role="alert"
oraria-live="assertive"
. - Move focus to the first error on submit so keyboard and screen-reader users aren’t lost.
- Ensure color isn’t the only signal. Pair red with an icon and text.
- Keep error text near the field it relates to and reference it via
aria-describedby
.
Instrumentation: Make Errors Observable
- Log to a client-side monitor (e.g., Sentry) with user context and feature flags.
- Track error rate, retry success rate, and “time stuck on loader.”
- Review “rage clicks,” dead ends, and abandoned forms to identify missing guidance.
Copy/Paste Checklist
- [ ] Do we have specific, helpful error copy for top 10 failure modes?
- [ ] Do async views include retry and timeouts?
- [ ] Do forms keep user input on failure and focus the first error?
- [ ] Are errors announced to assistive tech and visible without color?
- [ ] Are errors logged and reviewed regularly?
Closing Thought
You can’t prevent every failure, but you can engineer how failure feels. Design for the happy path; ship for the real world with clear, accessible, and recoverable error states.
Top comments (2)
Thoughtful post.
I strongly agree that errors should be logged and reviewed regularly—both from a shift-right, continuous-monitoring perspective and through retrospective UX analytics. In many cases, they aren’t instrumented precisely or accurately.
I’d add that errors should be categorised as Known Errors and Unexpected Errors; the status of each tells a different UX story.
Loved how you reframed UI quality around error states—a fresh take on the usual happy-path focus. The practical React/Next patterns and accessibility tips are super helpful; bookmarking the checklist.