React Native: Working with Error Boundaries

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

Ross Bulat
Jul 12 · 12 min read
Image for post
Image for post

Error boundaries act as fallback UI when errors occur

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:

Image for post
Image for post
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

  • 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?

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

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

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

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

...
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

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:

Image for post
Image for post
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

  • 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


In Summary

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.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

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