Mastering Error Handling in Frontend Applications: A Comprehensive Guide

Adarsh Rishishwar
Carousell Insider
Published in
9 min readMar 7, 2024

Introduction

Welcome to the enchanted world of frontend development, where applications come to life with vibrant user interfaces and dazzling features. However, just like any adventure, this journey is not without its challenges. One of the most formidable foes you’ll encounter is the elusive and unpredictable “Error.” In this blog post, we’ll equip you with the knowledge and tools to conquer these errors and create a seamless user experience in your frontend application.

Our Beautiful project under attack from Errors — Photo by Craig Adderley from Pexels

Chapter 1: Meet the Heroes — CustomError and Its Companions

In our quest to tame errors, we begin by crafting a team of trusty companions led by “CustomError”. Much like a wizard’s staff, CustomError extends the built-in JavaScript “Error” object to suit our application’s unique needs. With its magical properties like “code” and “data”, we can convey precise information about the error to aid in debugging.

export class CustomError extends Error {
code
data
constructor(code: string, message: string, data?: any) {
super(message)
// error doesn't pass to the prototype chain with typescript if target is less than es6
// https://github.com/Microsoft/TypeScript-wiki/blob/main/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work
this.name = code
this.code = code
this.data = data
}
}

export function isCustomError(candidate: any): candidate is CustomError {
return candidate instanceof CustomError || "code" in candidate
}

But CustomError is not alone on this journey. It spawns a legion of specialised error classes like “AuthenticationError”, “AuthorisationError”, “NotFoundError” and multiple corps. Each class has a distinct purpose, making it easier to discern and handle errors gracefully.

export class NotFoundError extends CustomError {
constructor(message = "404 - Not Found", data?: any) {
super(ERROR_CODES.NOT_FOUND, message, data)
}
}

export function isNotFoundError(candidate: any): candidate is NotFoundError {
return (
candidate instanceof NotFoundError ||
candidate?.code === ERROR_CODES.NOT_FOUND
)
}

Chapter 2: Mastering API Layer Error Handling

In our quest for error handling mastery, we must not forget the critical role played by the API layer. This layer acts as a gateway to external resources and services, making it essential to handle errors effectively.

const GeneralErrorMessage =
"Error: Please refresh and try again, or contact the support team.";

export const errorHandler = (error) => {
const { request, response, code } = error;
if (code) {
if (code === "ECONNABORTED")
throw new ServiceError("The server is taking too long to respond!");
}
if (response) {
const { data, status } = response;
const readableError = getReadableError(status, data);
throw readableError;
} else if (request) {
// Request sent but no response received
throw new ServiceError(GeneralErrorMessage);
} else {
const { status } = { ...error.toJSON(), ...{ status: 500 } };
const readableError = getReadableError(status);
throw readableError;
}
};
const getReadableError = (status: number, data?: any) => {
if (status <= 500) {
if (status === 404)
return new NotFoundError(data?.errorMsg ?? ErrorMessages[404])
if (status === 500)
return new ServiceError(data?.errorMsg ?? ErrorMessages[500])
if (status === 401)
return new AuthenticationError(data?.errorMsg ?? ErrorMessages[401])
if (status === 403)
return new AuthorisationError(data?.errorMsg ?? ErrorMessages[403])
if (status === 409 || status === 422)
return new ValidationError(data?.errorMsg ?? ErrorMessages[409])
return new ClientError(data?.errorMsg ?? ErrorMessages[status])
}
return new ServiceError(data?.errorMsg ?? GeneralErrorMessage, data)
}

Our API error handler examines the error object, handles different error scenarios, and throws custom error classes for specific situations, ensuring a consistent and structured approach to error handling.

Chapter 3: Managing the Kingdom — State Management for Errors

In the sprawling kingdom of frontend development, you’ll encounter a diverse array of errors. To rule with wisdom and grace, you must manage these errors with precision. That’s where state management systems like Redux come into play.

Photo by clark cruz

By integrating state management, you can seamlessly propagate errors to the state, enabling real-time feedback to users. Whether it’s gracefully handling unexpected errors or providing informative messages, a well-structured error handling strategy ensures a harmonious kingdom.

export const errorSlice = createSlice({
name: ERROR_SLICE_NAMESPACE,
initialState,
reducers: {
setError(
state,
{ payload: { code, message, data } }: PayloadAction<ErrorState>
) {
state.code = code
state.message = message
state.data = data
},
clearError(state) {
const { code, message, data } = initialState
state.code = code
state.message = message
state.data = data
},
},
})

Chapter 4: Intercepting Errors on the Battlefield — Redux Saga

Our journey takes us to the battlefield where API errors are formidable adversaries. To centralise API error handling, we call upon the wise sorcerer known as “Redux Saga”. With its mystical abilities, Redux Saga intercepts HTTP requests and responses, applying common error-handling logic.

const sagaTask = sagaMiddleware.run(saga)
sagaTask.toPromise().catch((error) => {
//dispatch appropriate actions to set error state
store.dispatch(setError(error))
})
Saga at Action- Photo by Levi Damasceno

Let’s dive into a practical example — adding a new item to your application using Redux Saga:

export function* addNewItem(action) {
const { payload: { name } } = action;

// Update the UI state to indicate item addition is in progress
yield put(addNewItemPending());

// Invoke the API to add a new item
const data = yield call(ItemsClient.addNewItem, {
data: { name },
});

if (data?.id) {
// Update UI state with success status
yield put(addNewItemSuccess());
// Display success message to the user
yield put(showToastMessageAction({
type: "success",
message: `Successfully added item: ${name}!`,
}));
} else {
throw new IAMError("Couldn't add new item");
}
}

In this example, we add a new item using Redux Saga. We update the UI state, make an API call, and handle success and error scenarios. Redux Saga allows us to throw appropriate errors, then we can dispatch these errors with actions to the Redux store, and manage them using our error state management solution, providing a unified approach to error handling.

Chapter 5: Error Boundaries — The Shields of Your Application

In the land of frontend development, errors can be treacherous beasts that threaten to crash your entire application. Fear not! We introduce you to the magical shields known as “Error Boundaries.” These guardians, inspired by React, encase parts of your application and shield them from the devastating effects of errors.

interface Props extends PropsFromRedux {
children: React.ReactNode
ifNotAuthenticated: () => void
onSupportClick: (errorMessage?: string) => void
}

class ModuleErrorBoundary extends Component<Props> {
state: {
error: Error | null
errorInfo: ErrorInfo
hasError: boolean
}
// triggered when the error is caught while rendering
static getDerivedStateFromError(error: Error) {
// set correct state for the next render
return { hasError: true, error }
}
// triggered when the error is updated in our store
static getDerivedStateFromProps(props: Props) {
const { error } = props
logger.info(error)
if (error.code) return { hasError: true, error }
return null
}
constructor(props: Props) {
super(props)
this.state = {
hasError: false,
errorInfo: { componentStack: "" },
error: null,
}
}
// logging the error
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
logger.error(error)
logger.error(errorInfo.componentStack)
}
render(): ReactNode {
const { hasError, error } = this.state
const { ifNotAuthenticated, children, onSupportClick } = this.props
if (hasError) {
if (isAuthenticationError(error) && ifNotAuthenticated)
ifNotAuthenticated()
const message = isCustomError(error)
? error.message
: `Sorry, something went wrong
Try to refresh the page or contact your admin`
return (
<>
<Box
sx={{
alignItems: "center",
justifyContent: "center",
display: "flex",
height: "100%",
}}
>
<Stack spacing={4}>
<Image alt="Error Image" loader={CustomLoader} src={ErrorImage} />
<P align="center" variant="caption">
{message}
</P>
{isNotFoundError(error) || isClientError(error) ? (
<Button
icon={
<Image
alt="reload"
loader={CustomLoader}
src={RealoadIcon}
/>
}
>
{RETRTY_LABEL}
</Button>
) : (
<Button
color="secondary"
icon={
<Icon variant="ContactSupportOutlined" fontSize="small" />
}
onClick={() => onSupportClick(error?.message)}
variant="text"
>
{SUPPORT}
</Button>
)}
</Stack>
</Box>
</>
)
}
return children
}
}
const mapState = (state: AppState) => ({ error: state.ERROR_SLICE })
const mapDispatch = {
clearError,
}
const connector = connect(mapState, mapDispatch)
type PropsFromRedux = ConnectedProps<typeof connector>
export const ConnectedModuleErrorBoundary = connector(ModuleErrorBoundary)

With Error Boundaries in place, your application can gracefully handle errors, display user-friendly messages, and offer options for recovery, much like a seasoned knight defending the kingdom.

Error Boundaries — Photo by Aleksandar Pasaric

Chapter 6: Applying Error Handling in Practice

Now that we’ve armed ourselves with the knowledge and tools for error handling in frontend development, let’s put it all into practice using a real-world example. Imagine you have a saga responsible for adding a new item to your application. We’ll walk you through how to apply the error handling techniques we’ve discussed.

export function* addNewItem(action: PayloadAction<AddItemRequest>) {
try {
const {
payload: { name },
} = action;

// Update the UI state to indicate that item addition is in progress
yield put(addNewItemPending());
// Invoke the API to add a new item
const data = yield call(ItemsClient.addNewItem, {
data: {
name,
},
});
if (data?.id) {
// Update the UI state with a success status
yield put(addNewItemSuccess());
// Display a success toast message to the user
yield put(
showToastMessageAction({
type: "success",
message: `Successfully added item: ${name}!`,
})
);
// Refresh the data in the items table or take other necessary actions
// (e.g., navigating to a different page)
// yield put(refreshItemsTableAction());
} else {
// If the API response does not contain an 'id', throw a custom IAMError
throw new IAMError("Couldn't add new item");
}
} catch (err) {
// Handle errors and update the UI state accordingly
yield put(addNewItemFailed());
// Handle specific error types (IAMError, ValidationError, etc.)
if (!isIAMError(err) && !isValidationError(err)) {
// If it's not one of the expected error types, propagate the error further
throw err;
}
// Display user-friendly error messages based on the error type
if (isValidationError(err)) {
// Display a validation error toast message
yield put(
showToastMessageAction({
type: "error",
message: "This item already exists!",
})
);
} else if (isIAMError(err)) {
// Display a generic IAM error toast message
yield put(
showToastMessageAction({
type: "error",
message: "Couldn't add new item. Please try again later.",
})
);
}
}
}

In this example, we’ve got a saga that adds a new item to your application. Here’s how you can use the error handling techniques we’ve discussed:

  • We start by updating the UI state to indicate that the item addition is in progress (addNewItemPending()).
  • We then make an API call to add the new item and check if the operation was successful by inspecting the API response.
  • If the response contains an ‘id,’ we update the UI state with a success status (addNewItemSuccess()), display a success toast message to the user, and potentially take other necessary actions like refreshing the data.
  • If the response does not contain an ‘id,’ we throw a custom IAMError to handle this specific error case. This allows us to gracefully manage the error and provide user-friendly feedback.
  • In the catch block, we handle errors, update the UI state with a failure status (addNewItemFailed()), and display user-friendly error messages based on the error type (validation error or IAM error).
  • If any unexpected error is found we throw it again. This error is caught by our Saga Middleware, which dispatches an action and updates the state with caught errors. This state is subscribed to by our error boundaries, which in turn can show the correct error views.

By following this example, you can apply the error handling techniques we’ve discussed to real-world scenarios in your frontend application, ensuring a smooth user experience and robust error management.

Celebrations — Photo by Rick Han

Conclusion: A Hero’s Journey

As we conclude our adventure through the enchanted world of frontend development, you’ve learned to wield the mighty “CustomError” and its companions, shield your application with Error Boundaries, harness the power of Redux Saga, and govern your kingdom with state management.

Mastering error handling in frontend applications is not just about defeating errors, but also about enhancing the user experience and ensuring the stability of your software. Armed with these tools and techniques, you’re now prepared to face any error that crosses your path on your heroic journey through frontend wonderland.

In the next instalment of our blog series, we’ll embark on a quest to explore the hidden realm of error logging and reporting. Join us as we uncover valuable insights into the errors lurking in your frontend application. Until then, happy coding and may your code be bug-free!

--

--