Introduction to Error Handling in React
Error handling is an important part of building reliable React applications. Errors can occur when an API request fails, a user submits invalid data, a component encounters an unexpected rendering problem, or an asynchronous operation rejects.
Without appropriate error handling, users may see blank screens, incomplete content, or interfaces that do not explain what went wrong. React applications should detect expected failures, present useful feedback, and recover whenever possible.
React supports several error-handling approaches. You can use JavaScript try...catch for operations that throw errors, manage request failures with component state, and use error boundaries to catch errors during rendering in descendant components.
Understanding React state and API fetching provides a useful foundation for the techniques covered in this guide.
Common Types of Errors in React
Different failures require different handling strategies. Identifying the type of error helps you choose an appropriate solution.
- Rendering errors: A component throws an error while rendering its output or executing certain lifecycle methods.
- API errors: A request fails because of network problems, unsuccessful HTTP responses, authentication issues, or unavailable services.
- Event-handler errors: An operation fails while processing a click, form submission, or other user interaction.
- Asynchronous errors: A Promise rejects or an asynchronous operation fails after the original function has returned.
- Validation errors: User input does not meet the application's requirements.
- Programming errors: Incorrect assumptions, invalid data access, or other bugs cause unexpected behavior.
A rendering error is not handled in the same way as a failed API request. Error boundaries are designed for certain errors in descendant component rendering, while request failures and event-handler errors generally require explicit application logic.
Using Try-Catch in React
JavaScript's try...catch statement handles exceptions thrown by synchronous code and rejected Promises when used with await inside an appropriate asynchronous function.
function calculateTotal(price, quantity) {
try {
if (price < 0 || quantity < 0) {
throw new Error("Values cannot be negative");
}
return price * quantity;
} catch (error) {
console.error("Calculation failed:", error);
return null;
}
}
const total = calculateTotal(100, 3);
console.log(total);
In this example, invalid values cause an error to be thrown. The catch block handles the error instead of allowing it to propagate to the caller.
For real applications, choose an appropriate fallback for the situation. Returning null can be reasonable for some optional calculations, but required operations should generally communicate failure explicitly rather than silently hiding it.
A try...catch block only handles errors thrown while executing its protected code. It does not automatically catch errors that occur later in unrelated event callbacks or asynchronous operations that have not been awaited.
Handling Errors in Event Handlers
Event handlers run in response to user interactions. React error boundaries do not automatically catch errors thrown by event handlers, so use explicit error handling when an operation may fail.
import { useState } from "react";
function SaveButton() {
const [error, setError] = useState("");
const [saved, setSaved] = useState(false);
async function handleSave() {
setError("");
setSaved(false);
try {
await saveData();
setSaved(true);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Unable to save your changes"
);
}
}
return (
<section>
<button onClick={handleSave}>Save changes</button>
{saved && <p>Changes saved successfully.</p>}
{error && (
<p role="alert">{error}</p>
)}
</section>
);
}
async function saveData() {
// Replace this example with a real asynchronous operation.
}
The handler catches a rejected Promise and stores a message in state. React then renders the appropriate feedback. The example's saveData function is a placeholder and must be replaced with the actual operation.
For asynchronous event handlers, make sure to await the operation inside the try block. Otherwise, a rejection from an unawaited Promise will not be caught by that block.
Handling API Errors
API requests can fail because of network interruptions, unsuccessful HTTP responses, invalid response data, or server-side problems. A robust React component should check the HTTP response status and manage errors explicitly.
async function fetchProducts() {
const response = await fetch(
"https://api.example.com/products"
);
if (!response.ok) {
throw new Error(
"Unable to load products: HTTP " + response.status
);
}
return response.json();
}
The Fetch API generally resolves its Promise even when the server returns HTTP errors such as 404 or 500. Checking response.ok ensures that unsuccessful status codes are handled instead of being treated as successful responses.
The example endpoint is illustrative. Replace it with your application's actual API URL.
For a complete request flow, combine API calls with loading, success, and error states. The React API Fetching guide explains these patterns in more detail.
Managing Loading and Error States
A component that fetches data should represent the request lifecycle clearly. Keeping loading and error states separate helps the interface display the correct information for each situation.
import { useEffect, useState } from "react";
function Products() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
async function loadProducts() {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/posts"
);
if (!response.ok) {
throw new Error("Failed to load products");
}
const data = await response.json();
setProducts(data);
} catch (err) {
setError(
err instanceof Error
? err.message
: "An unexpected error occurred"
);
} finally {
setLoading(false);
}
}
loadProducts();
}, []);
if (loading) {
return <p>Loading data...</p>;
}
if (error) {
return <p role="alert">{error}</p>;
}
if (products.length === 0) {
return <p>No records found.</p>;
}
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.title}</li>
))}
</ul>
);
}
export default Products;
This example demonstrates loading, error, empty, and success states. The sample endpoint returns posts rather than products, so the variable names are illustrative and should be adapted to the actual resource.
For production code, consider cancelling obsolete requests with AbortController and handling cleanup when a component unmounts or its request parameters change. This helps prevent stale responses from affecting the interface.
What Are Error Boundaries?
Error boundaries are React components that catch errors thrown while rendering their descendant component tree and display fallback UI instead of allowing the affected part of the interface to fail without a recovery mechanism.
A typical error boundary uses the class component lifecycle methods static getDerivedStateFromError() and optionally componentDidCatch(). Function components do not currently provide an equivalent built-in Hook for declaring an error boundary directly.
Error boundaries are useful around major application sections, complex widgets, dashboards, or independently rendered features. They can prevent an error in one part of an application from replacing the entire interface with a blank screen.
Creating an Error Boundary
The following example creates a reusable error boundary using a class component. When a descendant throws during rendering, the boundary switches to a fallback interface.
import { Component } from "react";
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = {
hasError: false
};
}
static getDerivedStateFromError() {
return {
hasError: true
};
}
componentDidCatch(error, info) {
console.error("Rendering error:", error, info);
}
render() {
if (this.state.hasError) {
return (
<section role="alert">
<h2>Something went wrong.</h2>
<p>Try refreshing or return to this section later.</p>
</section>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
getDerivedStateFromError() updates state so the next render displays the fallback UI. componentDidCatch() provides information that can be recorded by a logging or monitoring service.
A boundary catches errors in descendant rendering and certain lifecycle methods, but it does not catch errors from its own rendering, ordinary event handlers, or asynchronous callbacks such as timers and Promise handlers. Those require other error-handling strategies.
Using an Error Boundary in Your Application
Wrap the part of the component tree that should receive a fallback if a descendant fails. You can use one boundary around the whole application or several boundaries around independent features.
import ErrorBoundary from "./ErrorBoundary";
import Dashboard from "./Dashboard";
export default function App() {
return (
<main>
<h1>Admin Dashboard</h1>
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
</main>
);
}
If Dashboard or one of its descendants throws a rendering error, the boundary displays its fallback instead of the dashboard content. Other parts outside the boundary can remain available.
Place boundaries according to the recovery experience you want. A page-wide boundary is a useful safety net, while smaller boundaries can isolate independent widgets and keep unaffected features usable.
Recovering from Errors with a Retry Action
Some errors are temporary, such as a failed request or a rendering problem caused by stale data. A fallback can provide a retry action, but resetting an error boundary alone does not guarantee that the original problem is fixed.
A retry mechanism should reset the boundary's error state and, where appropriate, trigger a new data request or remount the affected component. The application should avoid retrying indefinitely without informing the user.
import { Component } from "react";
class RetryBoundary extends Component {
state = {
hasError: false
};
static getDerivedStateFromError() {
return { hasError: true };
}
handleRetry = () => {
this.setState({ hasError: false });
};
render() {
if (this.state.hasError) {
return (
<section role="alert">
<p>This section could not be displayed.</p>
<button onClick={this.handleRetry}>
Try again
</button>
</section>
);
}
return this.props.children;
}
}
This boundary resets its fallback state when the user retries. If the same rendering error happens again, the boundary will display the fallback again. In a real application, combine retry with an appropriate data refresh or reset strategy when the failure depends on external data.
Handling Form Validation Errors
Form validation errors are expected outcomes rather than unexpected application crashes. Handle them explicitly and associate error messages with the relevant fields so users can correct their input.
import { useState } from "react";
function SignupForm() {
const [email, setEmail] = useState("");
const [error, setError] = useState("");
function handleSubmit(event) {
event.preventDefault();
if (!email.trim()) {
setError("Email is required.");
return;
}
setError("");
console.log("Submit email:", email);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
value={email}
onChange={event => setEmail(event.target.value)}
aria-invalid={Boolean(error)}
aria-describedby={error ? "email-error" : undefined}
/>
{error && (
<p id="email-error" role="alert">
{error}
</p>
)}
<button type="submit">Sign up</button>
</form>
);
}
The form checks for an empty email and shows a message when validation fails. The aria-invalid and aria-describedby attributes help communicate the error to assistive technologies.
Client-side validation improves usability but does not replace server-side validation. The backend must independently validate submitted data before accepting or processing it.
Logging and Debugging React Errors
Useful error information helps developers identify the cause of a failure. During development, inspect the browser console, React component stack traces, network requests, and the values used by the failing component.
For production applications, consider reporting unexpected failures to an error-monitoring service. An error boundary's componentDidCatch() method can be a suitable place to record rendering errors and component stack information.
- Record the error message and relevant diagnostic context.
- Include request identifiers or operation names when useful for tracing failures.
- Avoid logging passwords, access tokens, private user information, or other sensitive values.
- Show users clear messages without exposing internal stack traces or implementation details.
- Distinguish expected validation failures from unexpected application errors.
Logging should help diagnose problems without creating additional privacy or security risks. See the React Best Practices guide for related development principles.
Common Error-Handling Mistakes
- Expecting error boundaries to catch every error: They do not automatically handle ordinary event-handler exceptions or errors from asynchronous callbacks.
- Ignoring unsuccessful HTTP responses: Check
response.okwhen using Fetch. - Swallowing exceptions: An empty catch block can hide failures and make debugging difficult.
- Showing only a blank screen: Provide useful fallback UI and recovery options where appropriate.
- Exposing technical details to users: Keep stack traces and internal server information out of normal user-facing messages.
- Ignoring cleanup: Cancel obsolete requests or guard against stale responses when components unmount or dependencies change.
- Retrying without limits: Use a sensible retry strategy and communicate persistent failures.
Best Practices for React Error Handling
- Use error boundaries to isolate rendering failures in appropriate parts of the component tree.
- Use
try...catchfor operations that can throw or reject and handle errors at the correct boundary. - Manage loading, success, and error states for API requests.
- Check HTTP status codes instead of assuming every response is successful.
- Provide actionable fallback messages and retry controls when recovery is possible.
- Keep validation feedback close to the relevant form fields.
- Log unexpected failures without exposing sensitive data.
- Test rendering failures, network failures, validation errors, and asynchronous rejections separately.
- Use server-side validation and authorization for operations involving user data or protected resources.
Conclusion
React error handling involves more than displaying an error message. Reliable applications distinguish rendering failures, API errors, event-handler exceptions, asynchronous failures, and validation problems, then apply the appropriate handling strategy.
Use error boundaries to protect component trees, try...catch for operations that can fail, and state-driven feedback for requests and forms. Add logging, cleanup, and recovery controls where they improve the user experience.
By handling failures deliberately, you can build React applications that remain understandable and recoverable when something goes wrong.