Filter by category
Throughout the years, I’ve worked on various React applications, and one of the most debated topics has always been error handling. My most recent experience with building such an app was no exception. Only this time, the debate was even more intense, as the topic itself has become more complex.
In this article, I will go through the issues my team and I have faced when building apps in React Native and the different approaches we’ve taken in handling them.
Let’s start by highlighting what we should pay attention to when talking about handling errors.
Firstly, if something goes wrong, we want to make sure our app won’t crash or end up in an irrecoverable state requiring a refresh (in the case of Web Apps) or a force quit (in the case of Mobile Apps).
Secondly, we want to inform our users whenever something goes wrong and provide information about the cause and what the user can do (if anything) about it. In this situation, we’re looking at two different cases:
Errors caused by invalid user input or action. Users must be informed of this type of error and how to adjust and continue their interaction. It's good to show these errors in context. Examples of such errors include invalid entries in a form, which we should ideally signal by highlighting the incorrect field.
Errors caused by a problem within our system. The users can't do much about this. At best, they can inform us of the errors and try again later. Examples include crashes caused by incorrect code or system overload.
Whenever we’re developing an app, we should be able to quickly detect errors and pinpoint where they take place and why. Larger teams have QA Engineers, whose job is to test the app and track bugs. In this case, the more information and context a QA provides, the easier it is for developers to reproduce and fix issues.
On Web Apps, we have access to the browser's native console, where anyone can see all the error messages and the failed requests along with the API's response.
On Mobile Apps, we don't have such a tool readily available. We can connect our phone to a laptop and view the console but this might prove difficult or impossible to do with some types of apps. Another thing we can (and should) do is integrate error monitoring tools like Sentry, Rollbar, or Crashlytics. The only downside is that they’re not easy to use either, especially when dealing with multiple users simultaneously.
In either case, there’s one thing that’s worth mentioning: communication. It’s customary to have some team members working on the Web/Mobile app (Front-end Developers) and others working on the API side (Back-end Developers). To be able to handle errors and understand them contextually:
Front-end Developers need to know what errors to look for and how to handle them
Back-end developers need to document the possible errors an API can throw
A break in communication usually leads to an unhandled error or an improperly handled one.
To illustrate the above-mentioned situations, we will look at a typical React app, with some pretty straightforward use cases. And what better way to exemplify this than with a TODO list app? We have two pages in this app:
One which lists all the TODOs, loaded from the server
Another one that allows the user to add a new TODO via a form submitted to the server
While the following list is by no means exhaustive, we will examine some of the typical errors that we’d like to be able to handle when dealing with a similar app:
It's good practice to have a general error handler for an internal server error with HTTP status code 500. In this case, we should show a toast with a generic message like "There's been an error. Please try again later". This error can happen on any API call, so such a handler should be present on all calls.
Form inputs generally have validation rules and, if the user inputs an invalid value, it's best to show a contextual error right next to the input. These validations generally happen on the API side, as well as on the UI side. In our case, we will only add it on the API and, to keep things simple, we’ll just color the input with a red border.
Here is a simple React app that implements this TODO list, but doesn't have any error-handling code. We’re using a mock server for the API, and we’ve defined the following API error structure:
If you want to follow along with this article, you can clone the repo or, if you have access to GitHub codespaces, create a codespace from this repo. Afterwards, start the React app.
To run the mock server in a separate terminal window, just run:
If we change the behavior of the APIs to make them throw errors, we will see that our app does nothing. It doesn't crash, nor does it show anything to the user. If we open the browser console, we will see that the errors are unhandled and that the requests have failed.
By now, we realized we have two different error-handling scenarios:
Generic API errors we would like to handle once, in a single place.
We would need to handle specific errors regarding an invalid field in the appropriate component.
Next, let's explore some error-handling techniques we’ve tried and study their pros and cons.
React comes with a solution for error handling in the form of error boundaries. However, as the documentation tells us, these boundaries are of no use for code that crashes in event handlers. They’re only useful for errors that appear during rendering.
So, while error boundaries are helpful and elegant in catching rendering errors, they don’t help accomplish our purpose.
The code that showcases the error boundaries usage is here, on the error-boundaries branch.
We can use various client libraries to make API requests and, for this case study, we’ll be using Axios. One of its neat features is the possibility of defining interceptors. They’re basic functions that get executed before a request is fired and/or after a response is received. They can be called either when the operation succeeds, or if and when it fails.
Here’s the next place we thought of for adding the error handling code. More specifically, the code that would address those generic API errors.
We've created a generic error handler that handles any internal server errors
We've created an Axios interceptor that uses our error handler
In the CreateTodo component, we’ve added the custom error-handling code
The entire code is here, on the axios-interceptor branch.
If we run the code and configure our mock server to throw errors, we’ll see that everything works just fine. And since we managed to write the generic error handler once and call it once from the interceptor, our initial requirements are met. Task completed!
However, there are a couple of things we’re not satisfied with, one more important than the other:
First, we still see unhandled errors in the console for the generic errors. It isn’t so bad, but if you are a completionist, it can be slightly annoying
Second, and this is the more important one, is our system's behavior in the face of change/evolution
Software is constantly changing and evolving and, for some reason, a new API error has emerged:
It’s yet another specific error, so we’ll need to handle it on the appropriate screen; it mustn’t be caught by the generic error handler. However, there’s another factor that comes into play in our scenario - one that’s fairly common when working in a team: the communication gap. The Back-end Developer didn't inform the Front-end Developer of this new change.
If we proceed to configure our create todo API to return this error, we’ll get… nothing. No error message appears, and the input doesn’t turn red. Something isn’t right.
On Web, things aren’t as bad since we have a reliable console to view the error and failed network requests. Thus, it’s easy for this error to be discovered by, or communicated to, the Front-end Developer and handled quickly. On Mobile, however, we don’t have a console or external tools we can rely on to catch such errors. Therefore, the situation is problematic and generally leads to bugs such as: ‘I clicked the button and nothing happened.’
Before we move on and tackle the second case we’ve just referred to - our system's behavior in the face of change/evolution - let’s first look and see whether we can eliminate those unhandled errors from the console.
The approach we thought of was: what if we catch the error in the interceptor? Easy fix.
Well, quite the contrary. This approach brings forward another issue: a new error appears in the console since now the API request seems to have stopped failing and the code tries to process the response. However, there’s no response. The API request failed and we have no other means to resolve the Promise. All in all, a bad idea (explained in more detail here). Delete.
It became clear that we’ve got bigger fish to fry, so we moved on to our second issue: the resiliency of our error-handling code when faced with new API errors.
It has been one of the most frustrating issues we’ve faced. We were constantly getting bug reports that things were ‘not working.’ but we didn’t have any context, errors, or stack traces; no nothing. We had to try to reproduce the issues or dig through logs in Sentry (our error reporting tool) and AWS CloudWatch (where our API logs were being aggregated).
In most cases, the root cause of the problem was that the API threw an error unknown to the Mobile App. Though the error wasn’t generic, it passed through the generic error handler. However, the screen didn’t handle it. We had to talk with Product and Design and receive specs on how to handle each error.
We aimed to display this type of error on the screen in an explicit manner, without resorting to generic error messages like ‘something went wrong.’ We wanted to show the exact error code we received from the API and be able to gather all the information we needed for the bug report. As a side note, Product only allowed us to do this in a non-production mode of the Mobile App.
Following up on the previously mentioned approach, we modified our interceptor to catch all the errors left unhandled in our components (or, generally speaking, errors that weren’t handled in any other place). Nonetheless, this wasn’t very specific, so we needed to define an array of all the errors our app knows how to handle and make sure that the errors not present in the array will be handled by the interceptor.
This is how the interceptor looks now (the entire code is here, on the axios-interceptor-knows-all branch):
By taking this approach, we could accomplish everything on our list, the only drawback being that it’s not very DRY. Besides the error handling code, we still need to add each error we explicitly want to handle to the array of known errors.
Lastly, we wanted to check whether being more explicit helps in doing things better. Keep in mind, though, that any error handled by the interceptor still appears in the console as an unhandled error.
After a couple of iterations, we finally took a KISS approach: explicitly handling errors when making API calls and dropping the interceptors altogether.
The error handler is again generic
In the CreateTodo component, we must explicitly handle the errors that need handling and also call the generic error handler for any other error
In the Todos component, we now have to explicitly call the generic error handler once more (there are no specific errors that need to handling there)
The entire code is here, on the explicit-error-handling branch.
We've managed to achieve a couple of things:
there are no more unhandled errors in the app (no errors in the console)
our generic error handler is indeed generic; it doesn't know about the specific errors we handle in the app
when we add a new specific error, we add it in a single place: the same place where we plan to handle it
There is one downside: we have to call the generic error handler everywhere. So, not very DRY this time either.
Of all the different approaches to handling errors in React Native that we’ve tackled here, the last application I worked on required us to choose between the last two:
using an interceptor that knows about all the explicit errors that we can handle in the components and that catches and handles everything else
explicitly handling all types of errors in the components and not using any interceptor
For the sake of correctness, we’ve decided to go for the second one for now. But the fact that we must always repeat the error handler doesn’t sit quite well with us. We keep thinking about the interceptor and, while we can get over the fact that we must add the error in two places, we don't like the unhandled errors. So, instead of a conclusion, I’ll just say to be continued…
Until then, if you’re working on a React Native app and would like to share some of your approaches to error handling, don’t hesitate. Drop me a line and let’s keep the conversation going. At least until most - if not all - errors are appropriately handled.
Cheers!