DEV Community

Cover image for Why Error States Define the Quality of Your UI
Sachin Maurya
Sachin Maurya

Posted on

Why Error States Define the Quality of Your UI

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

Prefer a battle-tested hook/component:

// Using react-error-boundary
import { ErrorBoundary } from "react-error-boundary";

function App() {
  return (
    <ErrorBoundary FallbackComponent={FallbackUI}>
      <Dashboard />
    </ErrorBoundary>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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" or aria-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)

Collapse
 
solo474 profile image
Solo

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.

Collapse
 
hammglad profile image
Hamm Gladius

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.