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.