The days when adding dynamism to a web page was limited to a few lines of jQuery are long gone. Modern web applications are complex ecosystems, interconnected with APIs, databases, and external services. This inherent complexity brings with it a constant and often underestimated challenge: error handling.
In my years of experience, I’ve seen how poor error handling can crush the user experience (UX), hinder the traceability of problems, and not less important, affect the developer experience (DX).
What happens when your application can't get data from the server? The possibilities are endless: an internal server error (5xx)? An authorization problem (4xx)? Perhaps your application expected a nested object like foo.bar, but bar never arrived in the response? Or a user encountered an unexpected corner case that caused a null object reference? A thousand things can go wrong (trust me, Murphy is always out there), and creating specific handling for each one is an impossible and impractical task. We need a broad and adaptable net to catch these exceptions.
But capture is only one part of the equation. Once an error happens, what do we do with it? Your application is not only built for the end-user; you also develop it for your colleagues and your future self. So, how is the error handled internally? How does the user perceive it? How do developers get the information needed to debug? Too many questions that point to a structured solution, and this is where "Error Boundaries" come into play.
The fundamental principles of error handling
The concept of "Error Boundary" refers to a strategy or mechanism designed to capture and handle errors that happens within a specific part of your code, without these errors crashing the entire application. This principle is the cornerstone of building robust applications.
To understand it better, let's define the key principles that guide effective error handling:
1. Don't Break the Application (or at least, don't break the whole thing)
This is the most critical principle. An error in one section of the application should not stop the entire flow or drastically ruin the user experience. Error Boundaries allow a part to fail in a controlled manner, while the rest of the application continues to function. This improves resilience and availability for the user.
2. Inform Intelligently (please, be clear)
It is not enough to avoid the collapse; it is also vital to inform the interested parties.
To the User: The user needs to know that something went wrong. However, they don’t need (and should not) see confusing technical details. A friendly message and an option to retry or contact support are usually enough. The key is to maintain transparency and trust.
To the Developer: Developers need all possible details to debug the problem: the type of error, the stack trace, the context in which it occurred, etc. A good error handling system facilitates the Developer Experience (DX) by providing the correct information for quick resolution.
3. Understand the Difference Between Controlled and Uncontrolled Errors
Controlled Errors are the ones we expect and can anticipate, generally originated outside of our main application. The classic example is server errors (HTTP 4xx/5xx), network failures, or data validation errors from APIs. These errors must be handled explicitly and transformed into information that the application can process and present in a controlled manner to the user.
Uncontrolled (Critical) Errors are unexpected failures and often indicate a bug in your application's own code (c’mon, you screwed it up). Think of programming logic errors, TypeErrors
(e.g., trying to access a property of undefined), or problems in the component lifecycle that TypeScript could not detect at compile time. For these, especially in development environments, we want the application to "explode" noisily. This "breakage" is a necessary alarm that forces the developer to correct the problem before it reaches production.
Error Handling Approaches: From Traditional to Robust
We can classify error handling strategies into two broad categories: the traditional approach and the robust approach.
The Traditional Approach: Dangers and Limitations
Unfortunately, the traditional approach is still the most common and the one I encounter in many projects (you'd be surprised how often I do). Often, it involves non-existent or scattered and individual error handling. It's surprising how recurrent and dangerous this is.
The manifestations range from the typical early return that simply does nothing in the face of an error, to catch blocks that only log the error to the console without any additional action.
// Example of poor traditional handling
async function fetchData() {
try {
const response = await api.get('/data');
// If the API fails, the catch logs it, but what do we show the user?
// What happens to the rest of the app?
return response.data;
} catch (error) {
console.error("Error getting data:", error);
// And now what? The UI receives no feedback and could remain in an inconsistent state.
return null;
}
}
Even more understandable handling where the error is not only logged, but exceptions are thrown, like:
// Example of throwing an exception
async function fetchDataAndThrow() {
try {
const response = await api.get('/secure-data');
return response.data;
} catch (error) {
console.error("Critical error getting data:", error);
throw new Error("API communication failure"); // Breaks the execution chain
}
}
Ok, nice, now you do something with the error, but this presents many problems because it breaks the "don't break the application" principle. While you are handling the errors in some way, the user experience ends up broken (a blank page, a React error, etc.), and the developer still has to go through a significant debugging process to understand why the application stopped (I won’t blame that poor soul if its programming and cursing the way this thing was done).
Real-Life Examples: From the Error Handling Hell
Let's look at a real code snippet from a project that tested my patience, similar to what one finds in existing projects. This function, getInvoice, aims to obtain and download an invoice, and it's a perfect example of how seemingly innocent error handling can drive you crazy and break the defined principles.
export const getInvoice = async (orderId: string, http: AxiosInstance | null, cancellation?: boolean) => {
let quoteUrl = ``;
const ordersUrl = `/some-url/${orderId}`;
try {
/* GET INVOICEID */
const response = await http?.get(ordersUrl);
// What if http is null?
if (response) {
const documentObject = response.data.data.documents.filter((element) =>
cancellation
? element.documentType === 'CANCELLATION_FUNDING' || element.documentType === 'CANCELLATION_DISBURSEMENT'
: element.documentType === 'INVOICE_TEMPLATE_PTC'
);
if (documentObject.length > 0) {
quoteUrl = `${API_URL_V2}/documents/${documentObject[0].documentId}/file`;
} else {
throw new Error('No matching document found');
// Business error thrown as an exception
}
}
} catch (err) {
console.log('Error fetching invoice ID:', err);
// Silence and return without feedback
return;
}
try {
/* DOWNLOAD INVOICE */
const response = await http?.get(quoteUrl, {
responseType: 'arraybuffer',
headers: { Accept: 'application/pdf' }, // Set the Accept header here
});
if (response) {
const BLOB = new Blob([response.data], { type: 'application/pdf' });
const PdfURL = URL.createObjectURL(BLOB);
const newWindow = window.open(PdfURL, undefined, 'popup=1');
if (newWindow) newWindow.opener = null;
}
} catch (err) {
console.log('Error downloading invoice:', err); // More silence
}
};
Yeah...
At first glance, the function uses try...catch, which seems like a good practice. However, when applying our principles, we identify several important gaps:
How getInvoice()
Breaks Our Principles?
Breaking the "Don't Break the Application" Principle: Silent Failures and Broken Flow
return;
in the First catch: If the first API call fails, the function simply does aconsole.log
and returns. This stops the execution silently. The code that invokesgetInvoice()
will never know if the operation succeeded or failed. The application could get stuck in an indefinite loading state or the user left without an explanation.Second Isolated try...catch: The invoice download has its own try...catch block that also only does a
console.log
. This means that even if theinvoiceId
was obtained, the PDF download could fail without the consumer knowing, resulting in a partially successful operation, but without feedback.throw new Error('No matching document found');
: This is a business error. It should be a controlled error and be communicated to the function's consumer explicitly (for example, by returning a{ data: null, error: 'NOT_FOUND' }
structure). By throwing an exception, we force the consumer to use their own try...catch or we run the risk that the application "breaks" noisily where the function is invoked, even if that was not the intention.
Ignoring the "Inform Intelligently" Principle: Lack of Context and Clarity
Generic Messages in console.log:
console.log('Error fetching invoice ID:', err);
is insufficient. In production, these logs are useless; there is no structured logging or sending to an error monitoring system. For a developer, the err without context can be difficult to understand and even find.Absence of User Feedback: The function has no mechanism to inform the end-user that something went wrong. If the PDF does not open, the user is basically in the dark now, which degrades the UX.
Confusing Controlled and Uncontrolled Errors:
The
throw new Error('No matching document found');
: this is a paradigmatic example. It is an expected error within the business logic, therefore, it is a controlled error. Throwing it as a general exception mixes the business logic with the flow control exception handling.The
http: AxiosInstance | null
: As you might pointed out, if http is null, it is a critical failure in the application's initialization or dependency injection. This is an uncontrolled (development) error that should "break" noisily in development, but in this example the function does not explode but simply does nothing if http is null, which is even worse for being a silent failure of a crucial dependency.
I think this illustrates quite well how important error handling is in an application, of course, but the question now falls on how am I supposed to fix that?
Let's talk a little about architecture first
So far, we've detailed the problems of deficient error handling. To build a sustainable and effective solution, we must step back and consider our application's architecture. A robust error handling system isn't a final add-on but an inherent design component. Understanding the application's structure and component responsibilities is vital to avoid "anti-patterns" and allows us to identify where errors should occur, how to handle them, and what information they should propagate. In modern applications, a Layered Architecture is an effective pattern that facilitates this separation of responsibilities and coherent error handling, the foundation of a complete system.
- Modeling Layer: responsible for defining the structure and shape of the expected data, especially those that come from external sources such as APIs, it prevents malformed or unexpected data from filtering to the upper layers of the application.
- Services Layer: responsible for communication with external APIs. It contains functions dedicated to making HTTP calls, handling network and protocol details, and processing raw responses. This is where we can apply concepts like Error as Data.
- State Layer: his layer manages the global and local state of the application. Consumes the output of the Services Layer and expose it to the next layer.
- UI Layer: Contains the React components that are responsible for rendering the user interface, interacting with the user, and consuming data from the State Layer.
The Robust Approach: A Strategic Solution
After diving into the swamps of poor error handling, it's time to breathe fresh air. The good news is that there are well-defined strategies to build applications that not only handle errors that we can use to improve the user and developer experience. This approach is based on the separation of responsibilities and clarity of contract in error communication.
This proposal is based on two fundamental pillars that, far from being mutually exclusive, complement each other perfectly:
Error Handling at the Data Level (Error as Data): an agnostic and broad way to attack the problem, portable and simple.
Error Handling at the UI Level (React Error Boundaries): the final step that communicates errors in a specific way, in this case in React.
Let's explore each one in detail and, most importantly, let's fix the getInvoice()
disaster and turn it from an example of "what not to do" to a model of good practices.
Shield your data and services with the model layer
It is useless to want to define limits for your errors if you don't even know what’s the shape of your data. The model layer would ideally live in its own model/
folder and inside, each model could be an entity. What we are looking for is to define how we expect the raw data and how we want it to be once processed.
// models/document.ts
// Optional: We define a schema for the expected API response of orders
// This assumes a structure like { data: { documents: [] } }
export const DocumentSchema = z.object({
documentType: z.string(),
documentId: z.string(),
// ... other properties if any
});
export const OrdersApiResponseSchema = z.object({
data: z.object({
documents: z.array(DocumentSchema),
}),
});
// We define the expected return type for our service function
interface GetInvoiceResult {
data: {
pdfUrl: string; // The blob URL to open the PDF
} | null;
error: ApiError | null;
}
When defining my initial limits, I usually base myself on the principle of never trusting the backend—and I mean a practical principle (nothing personal, I love you backend devs)—every system is created by humans, humans fail, therefore systems fail. If the backend lets something pass it shouldn't, sends the wrong type, or forgets to send something, my frontend is the one that will suffer the consequences.
The Services Layer: Error as Data
The cornerstone of our approach is to transform the errors of the services layer into explicit data that the function's consumer can understand.
Instead of throwing exceptions that break the flow, our service functions will always return an object with a predictable structure: { data: dataType | null, error: errorType | null }
. You can see a service as a simple factory, it will receive some parameters, it will process data and it will always return the same structure. Simple, understandable and familiar.
This pattern offers significant advantages:
Predictable Flow Control: The piece that calls your service will always know what to expect. You can check if error exists and, in that case, react in a controlled manner (show a message, a fallback component, etc.) without stopping the application flow.
Clear Separation of Responsibilities: The services layer focuses solely on communication with the API, response management, and normalization of any problems that arise from that interaction. The UI is responsible for presenting that information.
Framework Agnosticism: This service logic is independent of React. It could be consumed by any part of your application or even by other applications.
The meat: handleApiError
This is where we can go crazy, the backbone that merges our two layers and creates the boundaries is this function. What we are looking for is to directly create a flow that allows us to discriminate errors by types or patterns, serialize them and encapsulate them in a predictable object with all the information required so that the UI layer can react and the user is not left in limbo, as for developers to have all the necessary error information.
// api/utils/handleApiError.ts
import { AxiosError, isAxiosError } from 'axios';
import { ZodError, ZodIssue } from 'zod';
// Or your validation library (Valibot, etc.)
// Definition of custom errors if you have them (like MetadataError)
// class MetadataError extends Error { /* ... */ }
// Base structure for our normalized errors
export interface ApiError {
message: string;
code?: number | string;
data?: any; // This will contain the error details (Zod issues, Axios details, etc.)
type: 'validationError' | 'genericError' | 'axiosError';
userMessage?: string; // Friendly message for the user
}
// Helper function to create the error structure
const createApiError = ({ code, message, type, data }: Omit<ApiError, 'userMessage'>): ApiError => ({
code,
message,
type,
data,
});
// Function to map internal errors to user-friendly messages
// (You would need to implement handleErrorMessages according to your needs)
const handleErrorMessages = (error: ApiError): string => {
switch (error.type) {
case 'axiosError':
if (error.code === 404) return 'The requested resource was not found.';
if (error.code >= 500) return 'There was a problem with the server. Please try again later.';
return 'A communication error occurred. Try again.';
case 'validationError':
return 'The provided data is not valid.';
case 'metadataError':
return 'There was a problem with the system information.';
case 'genericError':
default:
return 'An unexpected error has occurred. Please contact support.';
}
};
// Main API error handling function
export const handleApiError = (apiName: string, error: unknown, extraDetails?: unknown) => {
// Internal function for developer logging
const logError = (err: ApiError) => {
// In a real project, here you would integrate with Sentry, Bugsnag, etc.
console.error(`[API Error - ${apiName}]`, err, extraDetails);
};
// You can add as many discriminators as you need
if (error instanceof ZodError) {
const err = createApiError({
code: 'VALIDERR',
message: 'Validation Error',
type: 'validationError',
data: error.issues, // Zod validation details
});
logError(err);
return {
error: { ...err, userMessage: handleErrorMessages(err) },
data: null,
};
}
if (isAxiosError(error)) {
// You can serialize the Axios error to save only what is necessary
const serialized = {
message: error.message,
name: error.name,
code: error.code,
status: error.response?.status,
data: error.response?.data,
config: error.config,
// ... other relevant properties
};
const err = createApiError({
code: serialized.status || serialized.code,
message: serialized.message ?? 'Http error',
type: 'axiosError',
data: serialized,
});
logError(err);
return {
error: { ...err, userMessage: handleErrorMessages(err) },
data: null,
};
}
// If none of the above fit, it is a generic (unexpected) error from the services layer.
// It is also treated as "data" for the consumer, but it is logged as a more serious error.
const err = createApiError({ code: 'GENERIC', message: 'Generic Error', type: 'genericError', data: String(error) });
logError(err); // Here logging is crucial
return {
error: { ...err, userMessage: handleErrorMessages(err) },
data: null,
};
};
This function is very straightforward and simple to understand, in addition to being easy to test and extend. Leaving aside the helpers, the main function is only responsible for two things: discriminating errors and creating the necessary object to return. The sky is the limit and you can add as many discriminators as you think necessary.
The two crucial concepts here are error.data
, which is the "vessel" where we throw every possible detail, and the block that defines an error as GENERIC, if for some reason the error consumed by the function does not fall into any of our discriminators, it will always be captured as generic and its data collected and shown to the developer, while the user will only see a friendly message.
Now finally… fixing the disaster
We can now (once and for all) fix the hell that was the getInvoice()
function, building on everything we've covered.
// services/invoiceService.ts
import { AxiosInstance } from 'axios';
import { handleApiError } from '../api/utils/handleApiError';
import { OrdersApiResponseSchema, GetInvoiceResult } from '@/model/document'
export const getInvoice = async (
orderId: string,
http: AxiosInstance // here axios instance MUST be break the app if not present
): Promise<GetInvoiceResult> => {
const ordersUrl = `/api-url/${orderId}`;
const API_URL_V2 = process.env.NEXT_PUBLIC_API_URL_V2;
try {
const ordersResponse = await http.get(ordersUrl);
// Validating the response with Zod
const parsedOrdersData = OrdersApiResponseSchema.parse(ordersResponse.data);
const documentObject = parsedOrdersData.data.documents.filter(
(element) => element.documentType === 'INVOICE_TEMPLATE_PTC' // Just simplifiying for the example
);
if (documentObject.length === 0) {
// Business error, no document found. Must be controlled
return {
data: null,
error: handleApiError('getInvoice - No Document Found', new Error('No matching invoice document found')),
};
}
const invoiceId = documentObject[0].documentId;
const quoteUrl = `${API_URL_V2}/documents/${invoiceId}/file`;
// PDF download process...
const pdfResponse = await http.get(quoteUrl, {
responseType: 'arraybuffer',
headers: { Accept: 'application/pdf' },
});
const BLOB = new Blob([pdfResponse.data], { type: 'application/pdf' });
const PdfURL = URL.createObjectURL(BLOB);
return {
data: { pdfUrl: PdfURL },
error: null,
};
} catch (rawError) {
// If anything on the process throws an error, it will be caught here.
return {
data: null,
error:handleApiError('getInvoice', rawError),
};
}
};
And with this, we are over it, let's move to the last steps.
React Error Boundaries: your UI's safety net
While our "Error as Data" approach in the services layer lets us handle API errors in a controlled way, React Error Boundaries focus on errors that can happen within the React component tree itself (just like you shouldn't completely trust the backend, you shouldn't completely trust the frontend either...). These errors can pop up during rendering, in component lifecycle methods (in class components), or even inside Hooks.
Why do we need error boundaries in react?
Even with robust error handling at data level, unexpected errors can still occur in the UI. Imagine a logic error in a component, an issue accessing a property of a state that hasn't been initialized yet, or an error in a third-party library. If these errors aren't caught, they can cause the entire React component tree to unmount, leaving the user with a blank screen or a generic and unhelpful error message (and few things are more frustrating for any human than a blank, inert screen, which, as a developer, bitterly reminds you that you're fallible).
Error Boundaries act as "safety nets" around parts of your UI. When an error happens within a component wrapped by an Error Boundary, the latter can catch the error, log it, and render a user-friendly fallback UI instead of the component that crashed. This has two main benefits:
Improves the User Experience: Instead of a catastrophic failure, the user sees a helpful message, and the application can keep running partially.
Makes Debugging Easier for the Developer: By catching the error, the Error Boundary can give valuable information about what went wrong, especially in development environments.
A smart implementation for development and production
Error Boundaries in React are implemented as class components that define the lifecycle methods static getDerivedStateFromError
and componentDidCatch
.
import React from 'react';
interface Props {
children: React.ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: React.ErrorInfo | null;
}
class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error: Error) {
// Update the state so the fallback UI is shown in the next render.
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Log the error (this will happen in all environments)
console.error("Error caught by Error Boundary:", error, errorInfo);
// You could integrate with a logging service here in production
// if (process.env.NODE_ENV === 'production') {
// logErrorToService(error, errorInfo);
// }
this.setState({ errorInfo });
}
render() {
if (this.state.hasError) {
// Fallback UI
return (
<div>
<h2>Something went wrong in this section!</h2>
{process.env.NODE_ENV === 'development' && (
<details style={{ whiteSpace: 'pre-wrap' }}>
{this.state.error && this.state.error.toString()}
<br />
{this.state.errorInfo && this.state.errorInfo.componentStack}
</details>
)}
{process.env.NODE_ENV === 'production' && (
<p>Please try reloading the page or contact support if the problem persists.</p>
)}
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
Is important to mention that I'm using the Class Components syntax because we need to be able to access to the component life cycle
getDerivedStateFromError
andcomponentDidCatch
, as react docs mentions, there's no way to use a functional component as error boundary.
How to use an Error Boundary
To protect part of your UI, simply wrap the components that might fail with your ErrorBoundary:
function MyApp() {
return (
<div>
<ErrorBoundary>
<PotentiallyProblematicComponent />
</ErrorBoundary>
<AnotherComponent />
</div>
);
}
If PotentiallyProblematicComponent
throws an error during rendering, the ErrorBoundary
will catch it and display the appropriate fallback UI for the environment, while AnotherComponent
will keep working normally.
Important considerations
Granularity: The strategic placement of your Error Boundaries is key. Wrapping smaller sections allows the rest of the application to keep running if one part fails.
Development vs. Production: Differentiating behavior based on the environment is crucial for good DX and UX.
Uncaught Errors: Remember that Error Boundaries don't catch errors in event handlers, asynchronous code, or in the Error Boundary itself. For these cases, you need specific error handling mechanisms (like try...catch blocks).
In conclusion, React Error Boundaries are a fundamental piece of a robust error handling strategy in the frontend. By combining them with the "Error as Data" pattern in the services layer, we build a resilient application that intelligently informs both users and developers when things don't go as planned.
And that's how we reach the end of the journey. I thought it was valuable to explain, based on my experience, the importance of proper error handling in modern applications. It only remains for me to say that I'm always open to different opinions and discussions.
Top comments (0)