Error handling is an important aspect of building software. We've automated error handling wherever possible so that we can deal with it in a consistent manner. By automating this process we've allowed our product engineers to focus on shipping quality software.
This blog is about how we automated handling and displaying error pages in neeto applications.
Before we jump in, let's look at Axios and Zustand, two npm packages we heavily rely on.
We use Axios to make API calls. Axios allow us to intercept API calls and modify them if necessary.
axios.interceptors.request.use(request => {
// intercept requests
});
axios.interceptors.response.use(request => {
// intercept responses
});
This feature is useful for tasks such as setting authentication tokens, handling case conversion for requests and responses, cleaning up sensitive headers when sending requests to third-party domains, etc. This is also a good place to universally handle any errors on API calls.
Zustand is a small, fast, scalable, barebones state management solution using simplified flux principles. We use Zustand for state management instead of Redux/React context. Here is a simple example of how to create a store using Zustand.
import { create } from "zustand";
const useBearStore = create(set => ({
bears: 0,
increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}));
To learn more about Zustand, check out there documentation.
Let's look into how we handle errors in our applications. As a first step, let's examine the universal Axios response interceptor we wrote to detect any error.
import axios from "axios";
axios.interceptors.response.use(null, error => {
if (error.response?.status === 401) {
resetAuthTokens();
window.location.href = `/login?redirect_uri=${encodeURIComponent(
window.location.href
)}`;
} else {
const fullUrl = error.request?.responseURL || error.config.url;
const status = error.response?.status;
//TODO: Notify the user that an error is occurred.
}
return Promise.reject(error);
});
For each response, if it detects a 401 HTTP error, the code resets the authentication tokens and redirects the user to the login page. For any other error, we need to notify the user with an appropriate message.
When an error happens, the user could be on any page, so we should be able to display an error message no matter which page the user is on. For that, we need to store the error in a way that it can be retrieved from anywhere. This is where Zustand comes into play. It is very easy to create a store in Zustand and can be used as a hook in React applications.
import { prop } from "ramda";
import { create } from "zustand";
const useDisplayErrorPage = () => useErrorDisplayStore(prop("showErrorPage"));
export const useErrorDisplayStore = create(() => ({
showErrorPage: false,
statusCode: 404,
failedApiUrl: "",
}));
export default useDisplayErrorPage;
The code snippet above creates a Zustand store for storing error-related data. Additionally, it provides a hook that conveniently checks whether an error has occurred anywhere within the application.
Back to our Axios interceptor, we can store the error using the
useDisplayErrorPage
hook.
import axios from "axios";
import { useErrorDisplayStore } from "./useDisplayErrorPage";
axios.interceptors.response.use(null, error => {
if (error.response?.status === 401) {
resetAuthTokens();
window.location.href = `/login?redirect_uri=${encodeURIComponent(
window.location.href
)}`;
} else {
const fullUrl = error.request?.responseURL || error.config.url;
const status = error.response?.status;
useErrorDisplayStore.setState({
showErrorPage: true,
statusCode: status,
failedApiUrl: fullUrl,
});
}
return Promise.reject(error);
});
Now, we can use the useDisplayErrorPage
hook at the root of our React
application. When an error happens, showErrorPage
will become true
; we can
use that to show an error page.
import useDisplayErrorPage from "./useDisplayErrorPage";
import ErrorPage from "./ErrorPage";
const Main = () => {
const showErrorPage = useDisplayErrorPage();
if (showErrorPage) {
return <ErrorPage />;
}
return <>Our App</>;
};
Let's look at the ErrorPage
component. The component reads the error data from
the Zustand stores and displays the appropriate error message and the picture.
import { useErrorDisplayStore } from "./useDisplayErrorPage";
import { shallow } from "zustand/shallow";
const ERRORS = {
404: {
imageSrc: "not-found.png",
errorMsg: "The page you're looking for can't be found.",
title: "Page not found",
},
403: {
imageSrc: "unauthorized.png",
errorMsg: "You don't have permission to access this page.",
title: "Unauthorized",
},
500: {
imageSrc: "server-error.png",
errorMsg:
"The server encountered an error and could not complete your request.",
title: "Internal server error",
},
};
const ErrorPage = ({ statusCode }) => {
const { storeStatusCode, showErrorPage } = useErrorDisplayStore(
pick(["statusCode", "showErrorPage"]),
shallow
);
const status = statusCode || storeStatusCode;
const { imageSrc, errorMsg, title } = ERRORS[status] || ERRORS[404];
return (
<div className="flex flex-col items-center justify-center h-screen">
<title>{title}</title>
<img src={imageSrc} className="mb-4" alt="Error Image" />
<div className="text-lg font-medium">{errorMsg}</div>
</div>
);
};
Ruby on Rails comes with default error pages for commonly encountered requests
such as 404, 500, and 422 errors. Each request has an associated static HTML
page located within the public directory. Even though we can customize them to
look like our ErrorPage
component, maintaining error pages in different places
will be difficult, and in the long run, they can go out of sync. So, we decide
to use the ErrorPage
component for these scenarios as well.
To accomplish this, we'll start by creating a controller named
ErrorsController
. This controller will contain a single show action, where
we'll extract the error code from the raised exception and render the
appropriate view.
class ErrorsController < ApplicationController
before_action :set_default_format
def show
@status_code = params[:status_code] || "404"
error = @status_code == "404" ? "Not Found" : "Something went wrong!"
if params[:url]
Rails.logger.warn "ActionController::RoutingError (No route matches [#{request.method}] /#{params[:url]})"
end
respond_to do |format|
format.json { render json: { error: }, status: @status_code }
format.any { render status: @status_code }
end
end
private
def set_default_format
request.format = :html unless request.format == :json
end
end
Next, we'll create a view
file for this show action. In this view file, we
will render the Error
component and pass error_status_code
to it as props.
<%= react_component("Error", { error_status_code: @status_code }, { class: "root-container" }) %>
In the Error
component, we will render the same ErrorPage
component created
above and pass the error status code received.
import React from "react";
import ErrorPage from "./ErrorPage";
const reactProps = JSON.parse(
document.getElementsByClassName("root-container")[0]?.dataset?.reactProps ||
"{}"
);
const Error = () => <ErrorPage status={reactProps.error_status_code} />;
export default Error;
Now add the following route in the config/routes.rb
file to point 404
and
500
requests to the show
action in the ErrorsController
.
Rails.application.routes.draw do
match "/:status_code", constraints: { status_code: /404|500/, format: :html }, to: "errors#show", via: :all
end
Finally, we need to tell the Rails application to use our new controller and
routes setup instead of those HTML templates in the public directory. For this,
add the following to the class Application < Rails::Application
block in
config/application.rb
file.
config.exceptions_app = self.routes
Sometimes, we get random requests from bots and crawlers to access the paths
that don't exist. When this occurs, our server raises an
ActionController::RoutingError
exception. This also happens when a user
mistypes a URL. However, In such cases, instead of just throwing the exception,
we should inform the user that what they are requesting does not exist by
showing a 404 page.
To handle this, we implemented a catch_all
route, as the name suggests, which
catches any routes that don't match already defined routes, which will be placed
at the end of our routes configuration. This placement ensures that it is only
called if a request doesn't match any other defined route.
Rails.application.routes.draw do
#Define other routes here...
#Catch-all route for handling errors
match ":url", to: "errors#show", via: :all, url: /.*/, constraints: -> (request) do
request.path.exclude?("/rails/")
end
end
This route also uses the same ErrorsController
mentioned above for handling
unmatched requests and will show a 404 page.
If this blog was helpful, check out our full blog archive.