Error Handling in React

1. 📖 Introduction

No application is bug-free, and network requests fail more often than we'd like. Error handling in React is about designing for that reality — catching failures gracefully, showing users something helpful instead of a blank screen, and giving developers enough information to fix problems quickly. This tutorial covers everything from Error Boundaries to API failures, form validation, logging, and production debugging.

Information

Good error handling is invisible when things go right and reassuring when they don't — the goal is never to eliminate errors entirely, but to contain and communicate them well.

2. 🧩 Understanding Errors in React

Errors in a React app generally fall into two buckets: errors that happen during rendering (which React can catch with special tools) and errors that happen outside rendering — in event handlers, timers, or async code — which React cannot catch automatically.

3. đŸ—‚ī¸ Types of Errors

  • Rendering errors — thrown while a component function executes
  • Runtime/JavaScript errors — logic bugs like calling a method on undefined
  • Asynchronous errors — failed API calls, rejected promises
  • Validation errors — user input that doesn't meet expected rules

4. âš™ī¸ Runtime Errors

A runtime error occurs when otherwise valid JS code fails during execution — for example, accessing a property on null, or calling a function that doesn't exist.

RuntimeError.jsx

function UserGreeting({ user }) {
  // Throws if user is undefined: "Cannot read properties of undefined"
  return <h1>Hello, {user.name}</h1>;
}

5. đŸ–ŧī¸ Rendering Errors

A rendering error is any error thrown while a component's function body is executing — during the initial render or a re-render. These are the errors that Error Boundaries (Section 8) are specifically designed to catch.

6. 📜 JavaScript Errors

Standard JavaScript error types — TypeError, RangeError, ReferenceError — behave the same way inside React components as anywhere else in JS, but their consequences differ depending on where they occur.

7. âŗ Asynchronous Errors

Errors thrown inside a Promise, setTimeout, or an event handler happen outside React's render cycle, so Error Boundaries cannot catch them — they must be handled manually with try/catch or .catch().

AsyncError.jsx

async function loadUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) throw new Error("Failed to load user");
    return await response.json();
  } catch (error) {
    console.error(error);
    throw error; // re-throw or handle locally
  }
}

Important

Error Boundaries only catch errors thrown during rendering, in lifecycle methods, or in constructors — they do not catch errors in event handlers, setTimeout callbacks, or async code.

8. đŸ›Ąī¸ Error Boundaries

An Error Boundary is a special component that catches JavaScript errors thrown anywhere in its child component tree during rendering, logging them and displaying a fallback UI instead of crashing the whole app.

9. đŸ—ī¸ Creating Error Boundaries

Error Boundaries must currently be written as class components, using the static getDerivedStateFromError and componentDidCatch lifecycle methods.

ErrorBoundary.jsx

class ErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error("Caught by ErrorBoundary:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

Tip

Community libraries like react-error-boundary provide a ready-made, hook-friendly Error Boundary component, avoiding the need to hand-write a class every time.

10. 🧩 Using Error Boundaries

Wrap Error Boundaries around logical sections of your app — a widget, a route, a sidebar — so a failure in one part doesn't take down the entire page.

UsingErrorBoundary.jsx

function App() {
  return (
    <div>
      <Header />
      <ErrorBoundary fallback={<p>Something went wrong loading the dashboard.</p>}>
        <Dashboard />
      </ErrorBoundary>
      <Footer />
    </div>
  );
}

Best Practice

Placing multiple, smaller Error Boundaries around independent sections is usually better than one giant boundary around the whole app — a failure stays contained rather than blanking the entire UI.

11. 🎨 Error Fallback UI

A good fallback UI explains what happened in plain language and, where possible, offers a way to recover — like a "Try again" button.

ErrorFallback.jsx

function ErrorFallback({ onRetry }) {
  return (
    <div role="alert">
      <p>We couldn't load this section.</p>
      <button onClick={onRetry}>Try again</button>
    </div>
  );
}

12. â™ģī¸ Error Recovery

A boundary can expose a way to reset its own error state, letting the user retry without reloading the entire page.

RecoverableBoundary.jsx

class ErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  handleRetry = () => this.setState({ hasError: false });

  render() {
    if (this.state.hasError) {
      return <ErrorFallback onRetry={this.handleRetry} />;
    }
    return this.props.children;
  }
}

13. 🌍 Global Error Handling

Beyond component-level boundaries, window.onerror and window.addEventListener("unhandledrejection") catch truly uncaught errors and rejected promises anywhere in the app — a useful last line of defense for logging.

GlobalErrorHandler.jsx

window.addEventListener("unhandledrejection", (event) => {
  console.error("Unhandled promise rejection:", event.reason);
  logErrorToService(event.reason);
});

14. 🌐 Handling API Errors

Always check the response status and distinguish between different error types — a 404 deserves different messaging than a 500 or a network failure.

ApiErrorHandling.jsx

async function fetchProduct(id) {
  const response = await fetch(`/api/products/${id}`);

  if (response.status === 404) {
    throw new Error("Product not found");
  }
  if (!response.ok) {
    throw new Error("Something went wrong loading this product");
  }
  return response.json();
}

15. 📡 Handling Network Errors

A network error — like a lost connection — causes fetch itself to reject, rather than returning a response object at all. This must be caught separately from HTTP error statuses.

NetworkErrorHandling.jsx

try {
  const response = await fetch("/api/products");
  // ...
} catch (error) {
  if (error instanceof TypeError) {
    console.error("Network error - check your connection");
  }
}

16. 📝 Handling Form Errors

Form errors should be stored in component state and displayed next to the relevant field, rather than as a single generic message at the top of the form.

FormErrorHandling.jsx

function SignupForm() {
  const [errors, setErrors] = useState({});

  const handleSubmit = async (e) => {
    e.preventDefault();
    try {
      await submitForm(formData);
    } catch (error) {
      setErrors({ form: "Signup failed. Please try again." });
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {errors.form && <p role="alert">{errors.form}</p>}
      {/* fields */}
    </form>
  );
}

17. ✅ Handling Validation Errors

Validation errors are typically produced before a request is even sent, by checking values against expected rules (required fields, formats, ranges).

ValidationErrors.jsx

function validate(values) {
  const errors = {};
  if (!values.email.includes("@")) errors.email = "Enter a valid email address";
  if (values.password.length < 8) errors.password = "Password must be at least 8 characters";
  return errors;
}

18. 🔑 Handling Authentication Errors

A 401 Unauthorized response usually means the session has expired — the standard response is to redirect the user to a login page, ideally preserving where they were headed.

AuthErrorHandling.jsx

api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      navigate("/login", { state: { from: location } });
    }
    return Promise.reject(error);
  }
);

19. âŗ Handling Suspense Errors

Components using Suspense for data fetching (via libraries built for it, like TanStack Query's suspense mode) throw errors that must be caught by a nearby Error Boundary, not by Suspense itself.

SuspenseWithErrorBoundary.jsx

<ErrorBoundary fallback={<p>Failed to load data.</p>}>
  <Suspense fallback={<Spinner />}>
    <ProductDetails />
  </Suspense>
</ErrorBoundary>

20. đŸĒĩ Logging Errors

Every caught error should be logged somewhere useful — at minimum the console during development, and a proper logging service in production.

ErrorLogging.jsx

componentDidCatch(error, errorInfo) {
  console.error(error, errorInfo);
  logErrorToService({ error: error.message, stack: error.stack, componentStack: errorInfo.componentStack });
}

21. 📡 Error Monitoring

Production apps typically integrate a dedicated monitoring service (like Sentry or LogRocket) that automatically captures errors, stack traces, and often session replays — giving visibility that console.error alone can't provide.

Reference

Most monitoring services provide a dedicated Error Boundary wrapper (e.g. Sentry.ErrorBoundary) that automatically reports caught errors, saving you from wiring up logging by hand.

22. 🐞 Debugging React Applications

React DevTools shows the component tree, current props/state, and highlights re-renders — an essential companion to the browser's built-in JS debugger for tracking down the source of an error, not just its symptom.

23. 📋 Stack Traces

A stack trace shows the sequence of function calls that led to an error, including — in development — the specific component in which it occurred, thanks to React's component stack.

24. đŸ—ēī¸ Source Maps

Production builds are typically minified, making raw stack traces unreadable. Source maps translate minified code locations back to the original source files and line numbers, which monitoring tools use to show readable traces.

25. 🔀 Development vs Production Errors

React shows much more detailed error overlays and warnings in development mode. In production, these are stripped for performance and security, so errors should be sent to a monitoring service rather than relying on what users see.

AspectDevelopmentProduction
Error overlayFull stack trace, component stackNone (silent unless boundary catches it)
WarningsVerbose console warningsStripped out
Debugging toolBrowser console + React DevToolsMonitoring service + source maps

26. đŸ•Šī¸ Graceful Degradation

When part of a page fails, the rest should ideally keep working — a failed recommendations widget shouldn't take down an entire product page. This is the core motivation behind scoping Error Boundaries narrowly (Section 10).

27. 🔷 TypeScript with Error Handling

Since caught errors are typed as unknown in TS, always narrow them before accessing properties like .message.

TypedErrorHandling.tsx

try {
  await submitForm(data);
} catch (error: unknown) {
  const message = error instanceof Error ? error.message : "An unknown error occurred";
  setErrors({ form: message });
}

28. 🏆 Best Practices

  1. Scope Error Boundaries around independent sections rather than one giant boundary at the root
  2. Always distinguish network errors, HTTP error statuses, and validation errors — they need different handling
  3. Log every caught error to a monitoring service, not just the console
  4. Give users a way to recover — retry buttons, links home — instead of a dead end
  5. Never silently swallow an error with an empty catch block

29. âš ī¸ Common Mistakes

  • Assuming Error Boundaries catch all errors, including those in event handlers or async code
  • Wrapping the entire app in a single Error Boundary, so any failure blanks the whole UI
  • Showing raw error messages or stack traces directly to end users
  • Forgetting to check response.ok, treating a failed HTTP request as a success
  • Catching an error and doing nothing with it, hiding real bugs from both users and developers

Danger

An empty catch {} block is one of the most damaging patterns in error handling — it silently discards information needed to diagnose a bug, and the user gets no feedback that anything went wrong at all.

30. đŸ’Ŧ Frequently Asked Questions

Do Error Boundaries catch errors in event handlers?

No — Error Boundaries only catch errors thrown during rendering. Errors in event handlers, setTimeout, or async callbacks must be handled with a regular try/catch.

Can I write an Error Boundary as a function component?

Not directly — getDerivedStateFromError and componentDidCatch currently require a class component. Libraries like react-error-boundary wrap this in a hook-friendly API if you'd rather avoid writing the class yourself.

Should I show the actual error message to users?

Generally no — raw error messages can expose internal details or simply confuse users. Show a friendly, actionable message instead, and log the technical details to your monitoring service.

31. 📌 Summary

>>Errors are inevitable — how gracefully your app handles them is what users actually remember.