Centralized Error Handing with React and Redux
Handling errors can be tedious given the added complexity of Redux actions and reducers but we can craft a centralized error handling mechanism.
Jan 23, 2020 • 13 Minute Read
Introduction
Tackling errors in a modern-day web app is a non-trivial problem. With multiple complex moving parts inside the same app, errors could occur from several sources. Of these, errors that occur in response to a user input are critical. For a proper UX, the errors should be propagated to the end-user in a human-friendly manner without breaking the entire app.
In a regular React and Redux app, handling errors could sound like a tedious task at first given the added complexity of Redux actions and reducers. Yet, using the very properties of Redux, it's possible to craft a centralized error handling mechanism. In this guide, we explore the idea of having a central location for handling API-born errors while minimizing rework. Then we extend the approach to handle manually triggered errors to present a fully-fledged error system.
The Design
To begin with, let's discuss the key expectations in implementing a centralized error handling system in our web app. While the requirements of apps may differ, the following are some of the tasks we as engineers regularly encounter.
Translating Error Codes to Human Readable Messages
When our app involves a backend that regularly interacts with the user inputs, it's common to have our own platform-specific set of errors defined in the backend. For example, the backend of a ToDo app would have errors like UnknownTodoID, TodoStateError, DuplicateToDo etc. These errors are not under the standard set of HTTP error types and are platform specific. Hence, the backend must communicate the error to the end user meaningfully. While having a hard-coded mapping in the frontend app specifying the error message for the error ID works for a smaller app, with increasing complexities it is best that the error message originates from the backend itself. Our error handler in the frontend should be able to capture the error and the error message from the backend and display it to the user.
Single Point of Handling
The errors, when handled, should have a central point of control. For example, you may later decide that all errors should fire an error event to a logging server for further analysis. If the errors are directly passed from the API calls to the error states, we would end up changing all such points of error origination. (Alternatively, a listener to the error store could work in Redux context, but this could drain performance significantly.)
Less Future Work
In developing hundreds of API fetches, we always prefer that the error handling code be automated as much as possible. In an ideal scenario, we should not explicitly check for errors in the response object from an API. Rather, the handling mechanism should figure it using the content of the response.
With the above requirement set in mind, we could now come up with a design for our centralized error handler. While there could be many design approaches for tackling the same problem, the following is a production-ready approach I use when the app uses a Redux store.
- First, decide on the shape of the response object you expect from your data sources.
- Structure the points of API interactions to make sure that the response objects and expected shape tally.
- Create an error reducer that observes any errors 'happening' in the app and registers them in the error state.
- Create a structure for firing errors manually with error actions.
- Finally, create reusable components for displaying the errors.
Response Object Shape
In designing APIs, there are several schools of thought on designing response objects, and each suits a specific set of use cases. In this guide, we will use a simple an object that suits an app that has regular exchange of small data portions between the frontend and the backend. Following is a JSON representation of the response.
{
"status": "ok" or "error",
"data": [] or {},
"messages": [],
"errors": []
}
In the above structure:
- data is where the actual response data from the API resides. It could be an array or a single object.
- messages is an array of strings. It consists of human readable strings indicating what happened to a successful request.
- errors is an array of string. It consists of readable strings saying what went wrong in the request.
- status is actually an optional field. For convenience, it could be "error" if something went wrong or "ok" otherwise. This is NOT the HTTP status or the code.
To simplify our error handling code in frontend, we would use this stripdown version of the above structure where messages and errors can only contain one string.
{
"status": "ok" or "error",
"data": [] or {},
"message": string,
"error": string
}
So with the above structure, we now know where exactly in a response we could find the error messages. But what if the data source is out of control? For instance, we could be contacting a third-party API to gather currency rates, and we can not dictate how the response object should look. For this, we need response transformation.
Structuring the API Calls
In order to give some context to our imaginary ToDo app, let's add up some code into the Todo types and actions. Note how the success and error action payloads are structured. They always contain a data or an error field.
// todoTypes.js
export const GET_TODO_REQUEST = "GET_TODO_REQUEST";
export const GET_TODO_SUCCESS = "GET_TODO_SUCCESS";
export const GET_TODO_ERROR = "GET_TODO_ERROR";
// todoActions.js
export function loadTodoRequest(){
return {
type: GET_TODO_REQUEST
}
}
export function loadTodoSuccess(results){
return {
type: GET_TODO_SUCCESS,
data: results,
error: null
}
}
export function loadTodoError(error){
return {
type: GET_TODO_SUCCESS,
data: null,
error: error
}
}
Let's create our first API call. In the following code, we assume that the response is structured properly as shown above.
// API call when the response shape is correct
import axios from 'axios';
export const loadTodos = () => {
return async function(dispatch) {
dispatch(loadTodoRequest());
try{
let response = (await exios.get("http://yourapi.com/todo/all")).data;
if(response.status == "ok"){
// check if the internal status is ok
// then pass on the data
dispatch(loadTodoSuccess(response.data));
}else{
// if internally there are errors
// pass on the error, in a correct implementation
// such errors should throw an HTTP 4xx or 5xx error
// so that it directs straight to the catch block
dispatch(loadTodoError(response.error));
}
}catch(error){
// any HTTP error is caught here
// can extend this implementation to customiz the error messages
// ex: dispatch(loadTodoError("Sorry can't talk to our servers right now"));
dispatch(loadTodoError(response.error));
}
}
}
As shown above, the only requirement in handling the response is that we need to redirect the data and error to the actual corresponding actions. Note that we can further generalize the implementation to accept the response object straightaway and remove the need for separately defining the error field and data field in the API call function. But that could be a potential security risk.
In case of an external API call, the only modification would be to the logic to figure out whether an error occurred. The following code demonstrates a sample situation.
export const loadRates = () => {
return async function(dispatch) {
dispatch(loadRatesRequest());
try{
let response = (await exios.get("http://rates.com/all")).data;
// has some error looks for known error patterns in return data
if(hasSomeError(response.data)){
dispatch(loadRatesError(response.error));
}else{
dispatch(loadRatesSuccess(response.error));
}
}catch(error){
dispatch(loadRatesError(response.error));
}
}
}
Now that the error origination is fixed, let's see how these errors that we submit to different error actions are actually caught by the error reducer.
The Error Reducer
The whole magic of centralized error handling in fact happens in the error reducer. The problem at the moment is that there are hundreds of error actions (loadTodoError, loadUserError, loadRatesError, and so on) getting fired by their respective API calls. How can we create a unified error reducer that will listen to all of them without having to be updated every time we have a new error action defined? We use a neat trick, or rather an inherent property in Redux reducers. Let's first see the reducer code.
// errorReducer.js
const initState = {
error: null
};
export function errorReducer(state = initState, action){
const { error } = action;
if(error){
return {
error: error
}
}
return state;
}
And that's it!
Why Does it Work?
In case you are wondering why the above code segment would work, it's quite simple. We are used to the pattern of a single reducer per action in most of our daily needs, so we tend to overlook the fact that reducers are simply condition checks that do not restrict an action into one. For example, an action being picked up by a reducer by checking its action type does not mean that it would be the only reducer to pick the same action. Redux would continue to pass along a fired action through every available reducer to see possible matches.
Using this property, in our case, we have an error reducer that checks for the existence of a non null error field in the action's payload. If it exists, it simply picks the action and captures the error message. This removes the need for explicitly defining the action types that need to be captured by the central error reducer.
Note: This will open further possibilities for a curious mind. Using the same approach, one could extend the same code to create a centralized notification handler that would capture and display not just errors but all different kinds of notifications.
You can now play around with the code and check if it works as expected. Fire an API call with an error and observe how it is captured in the error reducer's state correctly. Next, we need to be able to show the error manually.
Manually Triggering Errors
While API errors are the bigger mess, we sometimes need to explicitly show an error to the user. For instance, in a form submission, we can utilize the same error state to the feedback user of a validation error. For this, we need error actions.
// errorTypes.js
export const SET_ERROR = "SET_ERROR";
// errorActions.js
export function setError(error){
return {
type: SET_ERROR,
error: error
}
}
In any instance to set the error, you only need to call the above action and it will work as expected. Now that we have error handling figured, the last part is to show the error to the user so that they get proper feedback.
Displaying Errors
Showing errors is generally unique for any web app. For simplicity, let's create an ErrorNotification added to the very top of the component hierarchy of the app. The action of the component it simple: it shows a message if there's an error in the error store, and it clears store if the user dismisses the error. We need to modify the error reducer and actions to accommodate the new UI aspects.
// errorTypes.js
export const SET_ERROR = "SET_ERROR";
export const HIDE_ERROR = "HIDE_ERROR";
// errorActions.js
export function setError(error){
return {
type: SET_ERROR,
error: error
}
}
export function hideError(){
return {
type: HIDE_ERROR
}
}
// errorReducer.js
const initState = {
error: null,
isOpen: false
};
export function errorReducer(state = initState, action){
const { error } = action;
if(error){
return {
error: error,
isOpen: true
}
}else if(action.type === HIDE_ERROR){
return {
error: null,
isOpen: false
}
}
return state;
}
// ErrorNotification.jsx
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
const ErrorNotification = (props) => {
const isOpen = useSelector(state => state.errorReducer.isOpen);
const error = useSelector(state => state.errorReducer.error);
const dispatch = useDispatch();
function handleClose(){
dispatch({ type: HIDE_ERROR });
}
return (
<>
{isOpen && error && (
<div class="fancy-error-class">
<button>Close Error</button>
<span>{error}</span>
</div>
)}
</>
)
}
export default ErrorNotification;
One last step is to attach it to your app. The exact way to attach will greatly depend on your styling framework and the app structure. If it's a material-UI based app, I suggest that you create the error as a pop-up notification and attach at the very beginging of the app.
Conclusion
Error handling is a cumbersome task, especially in the frontend. Most of the errors that originate from an API end require feedback from the user. Hence, effectively communicating the error to the user is a critical UX aspect. Having a centralized error handling mechanism at the initiation of your project can greatly cut down the time you spend among the browser console. It prevents rigorous future refactoring of the codebase as well. I recommend that you put up an error handling structure at the very beginning of your project and extend it to a fully-fledged notification system.