React Native: Working with Error Boundaries

How to integrate and test error boundaries to gracefully contain errors in React Native apps

Error boundaries act as fallback UI when errors occur

When used with React Native, error boundaries allow you to implement fallback screens at certain points in your app that will be displayed when a JavaScript error occurs at runtime as a means to notify the user something went wrong. These fallback screens can then give the user a friendly explanation that something indeed went wrong, as well as an opportunity to restart the app or reset the UX to a previous state.

The following screencast demonstrates an error boundary in action simulated from a development build of an app I personally develop. This error boundary wraps the entire app and therefore responds to errors originating from any component in the hierarchy. An exception is thrown when I switch from the Settings tab to the Plans tab. This results in the fallback UI — defined in an ErrorBoundary component — being displayed. From here, the user can tap a button to restart the app, that also deletes user settings and other persisted state that may have caused the error:

Testing an error boundary in a release build in iOS Simulator

This exception was deliberately thrown for demonstration purposes (the app generally speaking is very stable!) using throw , that can be placed within a particular component:

throw new Error('Testing error boundary');

Error boundaries help apps “save face” when things go wrong, but are also integral to the app experience: in the event they are not implemented, the entire app would crash resulting in the user leaving it completely and guessing what went wrong in the process. This may even deter users from using your app again. Error boundaries are a simple solution to avoid such a scenario, and provide a means to keep the user in-app.

It is common to wrap an entire component hierarchy in an error boundary component, but it is also possible to wrap individual sections of your app in Error Boundaries to replace a certain part of the UI instead of the entire screen. An error will then fallback to the nearest error boundary in the hierarchy.

Concretely, at least one error boundary should be implemented per app to prevent the entire app crashing. An individual error boundary should wrap the entire component hierarchy, with additional error boundaries targeting sub-hierarchies with their own fallback UX.

What the article will cover

This article covers how to integrate and test error boundaries within a React Native app, demonstrating its capabilities and best practices:

  • Designing and implementing an error boundary. We’ll illustrate how one can design an error screen designed to wrap around your entire app hierarchy. This is done by creating an ErrorBoundary class component that implements specific APIs to contain the error and display the fallback screen. If no errors are present then props.children is simply rendered. This is managed through some local state management.
  • App data tidy-up and restart button. On the fallback screen will be a button that will allow a user to restart the app, taking the user back to the original screen in the process — this may be a sign in screen or a splash screen. We’ll also use this opportunity to carry out any data tidy-up before restarting, such as clearing any AsyncStorage values that may be causing the error. This will also allow the app to clear any authentication tokens and sign out a user if needed. We’ll use a simple API available from the react-native-restart package to elegantly restart the app.
  • Testing in debug and release builds for iOS. Also covered is how to test error boundaries both in a local development environment and in a release build with the iOS Simulator. Xcode schemes will be changed to run a Release build of your app instead of a Debug build. The reason we do this is because error boundaries behave slightly differently between the two environments — mostly due to the red error screen that overlays the screen when an error occurs. This is normal behaviour even when error boundaries prevent an app from crashing, so one can test a Release build instead to achieve more realistic production-build behaviour.

Note that error boundaries do not work in every part of your code — they will not work within event handlers for example. Consult the official documentation on error boundaries for up-to-date limitations of the feature.

Why do errors occur in the first place?

This is an excellent consideration that once should ask to minimise the chances of errors in their code. The tooling for testing component logic is well supported (See Testing in React with Jest and Enzyme: An Introduction). In addition, IDEs such as VS Code support linting, automatic formatting, syntax highlighting, and more with the free React and React Native plugins provided— so one may wonder how errors happen given these safeguards are already in place.

In my experience, in just about all cases, errors occur when there are changes to APIs — where additions are made to returned JSON or some properties are taken away — and the app has not been coded to handle such a scenario. These alterations are perhaps not considered at the testing stage of a release cycle, which will be more common when adopting a rapid iterative approach to developing an app, or doing continuous development based on user feedback that will eventually involve major changes and breakages from one version to the next.

Consider what would happen if the app was expecting a promo property of product. The app may have no safeguards in place and automatically assume product.promo is a valid field for all products. If the app already knows that promo is not guaranteed, then API changes could be made without breaking the app:

// unsafe 
const PromoDisplay = (props) => {
const { promo } = props.product;
...
}
// safe
const PromoDisplay = (props) => {
// longhand
const promo = props.product.promo === undefined
? null
: props.product.promo;
// or shorthand
const promo = props.product?.promo ?? null;
...
}

This trivial example highlights that the developer should make these safeguards to any field they think has a chance of changing in the future. Of course, this does not take away from best practices such as versioning your APIs and pushing app updates that reflect API changes in advance of releasing those updated APIs. Even doing this is no guarantee that your app will not have errors however — users may not immediately update their apps, for example.

Suffice to say, error boundaries definitely have their place in React Native apps — they can be thought of as the try-catch block for React components, that we’ve already ascertained can be very useful in the overall app experience, even if errors rarely occur.

This piece aims to aid the reader in quickly and efficiently integrating at least one error boundary within their React Native apps. Let’s get started by creating the ErrorBoundary component and highlight its characteristics in the process.

Integrating an Error Boundary Component

This section will walk through the creation of an ErrorBoundary component, and link to a completed Gist that will be similar to the implementation showcased in the screencast at the beginning on this piece. We’ll focus on a “global” error boundary component here — one that wraps your entire component hierarchy.

Since we are wrapping the entire component hierarchy, this “global” error boundary will not be subject to a Redux store, Navigation container, other contexts etc. Because of this, there are limited options you could offer the user in terms of navigating to a previous app state. Therefore, our ErrorBoundary component here will resort to restarting the whole app and wipe AsyncStorage data in the process, minimising the risk of the error happening again.

Component Structure

The React core devs have only implemented error boundaries as a class component, so functional components cannot be error boundaries — at least at the time of this writing.

In order to implement an error boundary, we need at least one of the following class methods implemented:

  • static getDerivedStateFromError(error): This static lifecycle method is invoked when an error occurs, and that error can originate from any child component of the error boundary. We are able to update component state in this method in its return statement, usually in the form of error: true, that will then cause a re-render and display the fallback UI defined.
  • componentDidCatch(error, errorInfo): This lifecycle method can also be defined, but is optional in the case that you define getDerivedStateFromError() too. It comes with an additional parameter, errorInfo, that contains information in the form of a stack trace to where the error originally occurred.

I prefer the simplified syntax of getDerivedStateFromError(), where one only needs to return the updated component state to invoke the error screen:

// using `getDerivedStateFromError` to update state on error occurringstate = {
error: false
}
static getDerivedStateFromError (error) {
return { error: true };
}

But this does not stop the developer from also implementing componentDidCatch() to log out of even break down the stack trace, or use it to determine what is displayed in the fallback screen. Optimistically speaking, your error screens should not be invoked too much, therefore I would argue that simply logging the errorInfo is enough from a development standpoint to ascertain where the error originated. This can be done by sending errorInfo to your backend servers in a simple fetch request:

// sending an error to your backend for further processingcomponentDidCatch(error, errorInfo) {  fetch(API_URL + '/log/error', {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
errorInfo: errorInfo
}),

method: "POST"
})
.catch(e => {
console.log('failed to send errorInfo');
})
}

Any error that occurs will be happening on a user’s device, so this utility is needed for developers to become aware of and deal with errors efficiently.

For iOS, app crashes are also reported in your App Store Connect Analytics, but these are opt-in and do not report stack traces relating to JavaScript or component hierarchy — App Analytics have no context or knowledge of the JavaScript layer.

With the two lifecycle methods implemented, we have two more things to worry about:

  • Rendering the correct UX depending on whether there is an error
  • And allowing the user to restart the app from the fallback UX, that will also wipe data from AsyncStorage.

The first point is straight forward — we simply render this.props.children under normal circumstances, and render the error screen when this.state.error is set to true:

// rendering error screen or children depending on this.state.errorrender () {
if(this.state.error) {
return(<View><Text>Oops. Error Screen!</Text></View>);
} else {
return this.props.children;
}
}

The gist to follow will display a more complex error screen that includes the button to restart the app.

Restarting the app and deleting inconsistent data

Let’s now turn our attention to doing just this — implementing a function that will wipe any “inconsistent” settings and restart the app accordingly.

We will adopt a simple package to perform the app restart, namely react-native-restart. Go ahead and add it to your project if you are following along now — it will automatically link with pods with React Native ≥0.60:

// install `react-native-restart` dependencyyarn add react-native-restart
cd ios && pod install && cd ..

react-native-restart does exactly what it says — provide a one-liner for invoking an app restart:

// `react-native-restart` apiimport RNRestart from 'react-native-restart'RNRestart.Restart();

This will be included in the button handler we’ll define next.

This API will take care of the app restart itself, but what about the inconsistent data concerns? Specifically, why would we want to wipe persisted data, and what makes it inconsistent?

Consider the point mentioned previously pertaining to updated APIs that lead to inconsistent data structures in-app. In the case that you have persisted an old data structure in AsyncStorage, you will want to force-ably remove that data and re-persist it with its updated structure when the user signs in again, or when the app loads from the splash screen. This at least is the most frictionless solution for the developer and app user to re-persist an updated data structure.

Signing in is commonly the time where user settings are returned and persisted on device. If your app does not have a sign-in mechanism, then the splash screen will be the best place to fetch up-to-date app data.

With this in mind, let’s create 2 more class methods that will delete user settings and restart the app respectively:

destroyUserSettings = async () => {
await AsyncStorage.removeItem('user_settings');
}
handleBackToSignIn = async () => {

// remove user settings
await this.destroyUserSettings();

// restart app
RNRestart.Restart();
},

async and await are used here to ensure that settings are removed before going ahead with the app restart.

And this is all that is needed for the global error boundary implementation — a component implementing some simple APIs in a powerful way to keep the user in your app when things to wrong, in a predictable manner.

The full Gist of this example component is included further down this article, and can also be viewed on here on Github.

Adding ErrorBoundary to the component hierarchy

Remember to wrap your entire hierarchy with ErrorBoundary as to cover errors that may occur from any component. This will likely be done from App.js:

...
import ErrorBoundary from './ErrorBoundary'
export const App = () => (
<ErrorBoundary>
<ReduxStore>
<ThemeManager>
<InAppPurchaseManager>
<NavigationContainer>
{/* etc... */}
</NavigationContainer>
</InAppPurchaseManager>
</ThemeManager>
</ReduxStore>
</ErrorBoundary>
);
export default App;

Click the above components to learn more about them in my other articles.

Before wrapping up, there are a couple of things to keep in mind when testing error boundaries in your development environment. With a development “Debug” build for example, the red error screen is always invoked when an error occurs, regardless of whether the error boundary has been implemented. This is frustrating as a developer, so the next section will cover how to test a Release build by changing Xcode scheme settings.

Testing Error Boundaries

In order to verify ErrorBoundary is working as expected, throw an error at a particular point in a component with the throw command. Throwing an exception on a particular screen is probably the most efficient way to do this:

const MyScreen = (props) => {  // IMPORTANT: remember to remove before building for production!
throw new Error('testing ErrorBoundary');
...
}

Now if you test this component now within your development build, perhaps with vanilla React Native or Expo, then you will notice the red warning screen appearing when the error is invoked:

In a Debug build the error screen will always appear, even if an error boundary has been integrated.

This behaviour can be tedious for testing purposes, where the developer has to dismiss this screen to view the error boundary in question every time. It also does not reflect the real production behaviour when an error occurs.

For iOS apps, this can be addressed by building for release within Xcode.

Changing Xcode scheme settings to run a Release build

For a more realistic error boundary experience, let’s turn our attention to the Xcode scheme settings to change the build to a Release build:

  • In Xcode, go to Product, Scheme, Edit Scheme from the main menu.
  • Under the Run sidebar option, change the Build Configuration dropdown from Debug to Release.
  • Click Close.

Your app will now build as a release build. Before doing so, make sure you do the following to ensure your latest JavaScript code is considered:

  • Switch your APIs from localhost to your production endpoints, as the Release build would be attempting to connect to localhost on the Simulator device, rather than localhost on your development machine.
  • Run expo publish if you are in an Expo managed project. This JavaScript bundle will be used in the Release build of your app.

Now you’re good to go. Click the Run button in Xcode to build a Release version of the app in the Simulator. The error boundary experience will now reflect the behaviour of a production build without any development prompts such as the error screen getting in the way.

On a final note, remember to remove any test exceptions within your components and rebuild your JavaScript bundle with expo publish again after your ErrorBoundary testing is finished. We don’t want deliberate errors being pushed to the App Store!

If you are publishing development builds to different Expo channels, be sure to publish test release builds to a separate channel to your production builds.

ErrorBoundary Component as a Gist

Here is the ErrorBoundary component implementation in full:

In Summary

This article has acted as a walkthrough for developers wanting to implement the error safe-guard for React Native components, being the error boundary APIs build into the React library. Implementing at least one global error boundary is integral to prevent unexpected app behaviour and ultimately throwing users out of the app upon an error happening.

This piece also includes a full example of an ErrorBoundary component in a Github Gist that the reader can refer to and adopt in their own projects. Alternatively, the full Gist can be viewed here on Github.

Programmer and Author. Director @ JKRBInvestments.com. Creator of LearnChineseGrammar.com for iOS.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store