Back to Blog

React Native Error Boundaries - Advanced Techniques

Explore advanced error handling techniques in React Native with special components like Feature, Partial, and Server Error Boundaries

February 7th, 2024

9 min read

by @rgommezz

Photo generated by Dall-E

Introduction

To this day, it still surprises me how developers have barely tapped into the power of error boundary components in the context of React Native. Perhaps it's their quirky class-based syntax, or the fact that they are not as popular as other React features, but I believe they are a valuable tool that, when combined with the right design patterns, can significantly enhance your application's resiliency.

Introduced in React 16, they serve as a means to catch JavaScript errors anywhere in their child component tree, log them, and display a fallback UI instead. Think of it as a JavaScript catch {} block, but tailored for components.

A common misconception is assuming that error boundaries catch all types of errors. However, they exclusively apply to rendering, lifecycle methods, and constructors.

This implies that error boundaries do not capture errors for:

  • Event handlers: For React Native events like onPress on touchables or onChangeText on inputs, you need to use a regular try/catch block to wrap your handler logic.
  • Asynchronous code: Such as fetching data from a server, where you would use a try/catch clause or the .catch() method on a promise object.
  • Errors thrown in the error boundary itself

This article will explore different types of error boundaries in the context of mobile applications, offering implementation proposals to address the scenarios outlined above.

The glorious top-level error boundary

This is the bare minimum recommended to protect your application from unexpected crashes, which, in the context of mobile applications translates to preventing your app from quitting unexpectedly.

An Error boundary in React Native is implemented by defining a couple of methods in a class-based component, getDerivedStateFromError and componentDidCatch. Quoting from React docs:

If you define getDerivedStateFromError, React will call it when a child component (including distant children) throws an error during rendering. If you define componentDidCatch, React will call it when some child component (including distant children) throws an error during rendering.

Wait a sec, isn't that a similar definition? It is indeed; there are no typos (I double-checked). The key difference is that getDerivedStateFromError is called during the rendering phase, whereas componentDidCatch is invoked after re-rendering, asynchronously.

getDerivedStateFromError, as the name suggests, returns the new local state object for the component, and componentDidCatch is a place for side effects, like calling your error reporting service.

A basic implementation of an Error Boundary would be as follows:

// ErrorBoundary.tsx import React, { Component } from "react"; import { View, Text } from "react-native"; import { ErrorReporting } from "@services/error"; class ErrorBoundary extends Component { constructor(props) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error, errorInfo) { // You can log the error to an error reporting service like Sentry ErrorReporting.logErrorToMyService(error, errorInfo); } render() { if (this.state.hasError) { // Please display something nicer to the user lol return ( <View> <Text>The app was about to crash, but I got ya!</Text> </View> ); } return this.props.children; } } export default ErrorBoundary;
// ErrorBoundary.tsx import React, { Component } from "react"; import { View, Text } from "react-native"; import { ErrorReporting } from "@services/error"; class ErrorBoundary extends Component { constructor(props) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error, errorInfo) { // You can log the error to an error reporting service like Sentry ErrorReporting.logErrorToMyService(error, errorInfo); } render() { if (this.state.hasError) { // Please display something nicer to the user lol return ( <View> <Text>The app was about to crash, but I got ya!</Text> </View> ); } return this.props.children; } } export default ErrorBoundary;

To use this error boundary, you would wrap your entire app with it, like so:

// App.tsx import React from "react"; import { ErrorBoundary } from "@components"; import { RootNavigator } from "@navigation"; const App = () => { return ( <ErrorBoundary> <RootNavigator /> </ErrorBoundary> ); }; export default App;
// App.tsx import React from "react"; import { ErrorBoundary } from "@components"; import { RootNavigator } from "@navigation"; const App = () => { return ( <ErrorBoundary> <RootNavigator /> </ErrorBoundary> ); }; export default App;

If any of the components in your app throws an error during the rendering phase, the error boundary will catch it and display a fallback UI. Your error reporter can take a break, but would your users be delighted?

Consider that once the fallback UI is displayed, your users won't be able to navigate away, facing a dead end. This limitation arises from being outside the global navigation scope, and in a mobile app, the only recourse for your users would be to close and (hopefully 🙏) reopen the app.

Now is the perfect time to introduce the first type of boundary that can help you avoid negative reviews in the app stores, as well as unpleasant surprises in your analytics dashboard.

You wondering why your analytics dashboard shows 0 active users
You wondering why your analytics dashboard shows 0 active users

Feature Error Boundaries

When implementing your navigation hierarchy, you would typically use a switch or conditional navigator as your root navigator to determine the app's path based on user authentication.

This means that when users are not signed in, you should only render navigators and screens that facilitate the authentication flow. Conversely, when users are logged in, you would discard the authentication flow's state, unmount all screens related to authentication, and render a different navigator instead.

Let's illustrate this with a simple example. I will extend the previous code to provide a basic implementation for the RootNavigator component.

// App.tsx import React from "react"; import { NavigationContainer } from "@react-navigation/native"; import { ErrorBoundary } from "@components"; import { AuthStackNavigator } from "@features/auth"; import { HomeStackNavigator } from "@features/home"; import { useAuth } from "@services/auth"; function RootNavigator() { const { isSignedIn } = useAuth(); const ActiveNavigator = isSignedIn ? HomeStackNavigator : AuthStackNavigator; return ( <NavigationContainer> <ActiveNavigator /> </NavigationContainer> ); } function App() { return ( <ErrorBoundary> <RootNavigator /> </ErrorBoundary> ); } export default App;
// App.tsx import React from "react"; import { NavigationContainer } from "@react-navigation/native"; import { ErrorBoundary } from "@components"; import { AuthStackNavigator } from "@features/auth"; import { HomeStackNavigator } from "@features/home"; import { useAuth } from "@services/auth"; function RootNavigator() { const { isSignedIn } = useAuth(); const ActiveNavigator = isSignedIn ? HomeStackNavigator : AuthStackNavigator; return ( <NavigationContainer> <ActiveNavigator /> </NavigationContainer> ); } function App() { return ( <ErrorBoundary> <RootNavigator /> </ErrorBoundary> ); } export default App;

Let's delve into the HomeStackNavigator component and define its internal navigation structure.

It will consist of two screens: HomeScreen, serving as the entry point once users are signed in, and a ProfileNavigator, accessible from the HomeScreen.

All profile-related logic has been consolidated into this distinct navigator, aiding in the establishment of clear boundaries for each feature.

In a mobile application, navigators feel like the natural way to separate different domains, not only from a code organization standpoint but also by enforcing a minimal API surface to communicate between different features. This is a topic that I'll cover in depth in another article, so stay tuned!

The ProfileNavigator itself also presents a couple of screens: one for viewing the user profile and a second one for editing it.

// HomeStackNavigator.tsx import React from "react"; import { createStackNavigator } from "@react-navigation/stack"; import { HomeScreen } from "./screens"; import { ProfileNavigator } from "@features/profile"; const HomeStack = createStackNavigator(); function HomeStackNavigator() { return ( <HomeStack.Navigator> <HomeStack.Screen name="Home" component={HomeScreen} /> <HomeStack.Screen name="Profile" component={ProfileNavigator} /> </HomeStack.Navigator> ); } export default HomeStackNavigator;
// HomeStackNavigator.tsx import React from "react"; import { createStackNavigator } from "@react-navigation/stack"; import { HomeScreen } from "./screens"; import { ProfileNavigator } from "@features/profile"; const HomeStack = createStackNavigator(); function HomeStackNavigator() { return ( <HomeStack.Navigator> <HomeStack.Screen name="Home" component={HomeScreen} /> <HomeStack.Screen name="Profile" component={ProfileNavigator} /> </HomeStack.Navigator> ); } export default HomeStackNavigator;
// ProfileNavigator.tsx import React from "react"; import { createStackNavigator } from "@react-navigation/stack"; import ProfileScreen from "@screens/ProfileScreen"; import EditProfileScreen from "@screens/EditProfileScreen"; const ProfileStack = createStackNavigator(); function ProfileNavigator() { return ( <ProfileStack.Navigator> <ProfileStack.Screen name="Profile" component={ProfileScreen} /> <ProfileStack.Screen name="EditProfile" component={EditProfileScreen} /> </ProfileStack.Navigator> ); } export default ProfileNavigator;
// ProfileNavigator.tsx import React from "react"; import { createStackNavigator } from "@react-navigation/stack"; import ProfileScreen from "@screens/ProfileScreen"; import EditProfileScreen from "@screens/EditProfileScreen"; const ProfileStack = createStackNavigator(); function ProfileNavigator() { return ( <ProfileStack.Navigator> <ProfileStack.Screen name="Profile" component={ProfileScreen} /> <ProfileStack.Screen name="EditProfile" component={EditProfileScreen} /> </ProfileStack.Navigator> ); } export default ProfileNavigator;

It's important to note that when defining a Stack.Screen with a component that renders a navigator, the displayed UI will be the first child screen by default. However, you can use the initialRouteName prop to specify a different entry point for the navigator. In this case, users will initially navigate to the ProfileScreen.

As it stands, any rendering errors occurring on either the ProfileScreen or the EditProfileScreen components will propagate up and be captured and handled by the application-level ErrorBoundary.

This design choice aims to narrow down the error-capturing surface deeper within the React tree, by setting it at the navigator level.

The previously defined ErrorBoundary can be easily extended with a fallback prop to customise what to display to the user in a case by case basis. For a feature/navigator error boundary, it makes perfect sense to present a button to allow the user to navigate back to the previous screen.

// ProfileNavigator.tsx import React from "react"; import { View, Text, Button } from "react-native"; import { createStackNavigator } from "@react-navigation/stack"; import ProfileScreen from "@screens/ProfileScreen"; import EditProfileScreen from "@screens/EditProfileScreen"; import { ErrorBoundary } from "@components"; const ProfileStack = createStackNavigator(); function ProfileNavigator({ navigation }) { return ( <ErrorBoundary fallback={ <View> <Text>Ups! Something went wrong</Text> <Text> Our team has been notified and will get this fixed for you ASAP </Text> <Button onPress={navigation.goBack}>Go back</Button> </View> } > <ProfileStack.Navigator> <ProfileStack.Screen name="Profile" component={ProfileScreen} /> <ProfileStack.Screen name="EditProfile" component={EditProfileScreen} /> </ProfileStack.Navigator> </ErrorBoundary> ); } export default ProfileNavigator;
// ProfileNavigator.tsx import React from "react"; import { View, Text, Button } from "react-native"; import { createStackNavigator } from "@react-navigation/stack"; import ProfileScreen from "@screens/ProfileScreen"; import EditProfileScreen from "@screens/EditProfileScreen"; import { ErrorBoundary } from "@components"; const ProfileStack = createStackNavigator(); function ProfileNavigator({ navigation }) { return ( <ErrorBoundary fallback={ <View> <Text>Ups! Something went wrong</Text> <Text> Our team has been notified and will get this fixed for you ASAP </Text> <Button onPress={navigation.goBack}>Go back</Button> </View> } > <ProfileStack.Navigator> <ProfileStack.Screen name="Profile" component={ProfileScreen} /> <ProfileStack.Screen name="EditProfile" component={EditProfileScreen} /> </ProfileStack.Navigator> </ErrorBoundary> ); } export default ProfileNavigator;
// ErrorBoundary.tsx import React, { Component } from "react"; import { View, Text } from "react-native"; import { ErrorReporting } from "@services/error"; const defaultErrorElement = ( <View> <Text>The app was about to crash, but I got ya!</Text> </View> ); class ErrorBoundary extends Component { constructor(props) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error, errorInfo) { // You can log the error to an error reporting service like Sentry ErrorReporting.logErrorToMyService(error, errorInfo); } render() { if (this.state.hasError) { return this.props.fallback || defaultErrorElement; } return this.props.children; } } export default ErrorBoundary;
// ErrorBoundary.tsx import React, { Component } from "react"; import { View, Text } from "react-native"; import { ErrorReporting } from "@services/error"; const defaultErrorElement = ( <View> <Text>The app was about to crash, but I got ya!</Text> </View> ); class ErrorBoundary extends Component { constructor(props) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error, errorInfo) { // You can log the error to an error reporting service like Sentry ErrorReporting.logErrorToMyService(error, errorInfo); } render() { if (this.state.hasError) { return this.props.fallback || defaultErrorElement; } return this.props.children; } } export default ErrorBoundary;

Great! Now you can give yourself 10 error handling points. Your users won't need to restart the app again when something goes south inside your render logic.

Partial Error Boundaries

Imagine a complex screen that's pulling data from various remote sources, like a weather app that shows different weather details like temperature, humidity, precipitation, and wind, all on one screen using separate charts.

Each chart gets its data from a different endpoint, and the app arranges them in a grid layout, so users get a quick look at the forecast for a specific location.

Weather widgets, by Mateusz Madura
Weather widgets, by Mateusz Madura

Now, let's say the wind data source throws an error while the app is rendering. That's when the feature/navigator error boundary steps in. It catches the error and provides users with an alternative UI, with a button to go back.

While this approach works, it's not perfect because it doesn't handle errors deeper within the component tree, specifically at the widget level. As a result, other weather indicators that, in theory, aren't affected, can't be displayed when at least one fails.

That's where Partial Error Boundaries come in handy. They let you narrow down error handling to specific sections within a screen.

However, this concept might seem a bit abstract, which naturally leads to some questions:

  • Where should I apply a Partial Error Boundary?
  • Should I protect all my components with a Partial Error Boundary?
  • How should I address server errors, especially since they are asynchronous and typically handled using a try/catch block or the catch() method on a promise object?

Server Error Boundaries

Server Error Boundaries are components that embrace the concept of partial error boundaries, serving two pivotal purposes:

  • Shielding a specific portion of your screen against unexpected server responses, such as exceptions like 'undefined is not an object' in your rendering code.
  • Serving as an intermediary layer for managing asynchronous errors that might slip through a default boundary, such as server rejections.

In essence, this involves having a component that abstracts all error handling away, regardless of its source.

In the upcoming code snippets, I'll assume you have some familiarity with TypeScript, so hopefully you won't mind the addition. I skipped it in the initial examples as it played a minor role, but for this specific implementation, I'll utilize generics to enhance the code's flexibility, reusability, and type safety.

Alrighty, let's delve first into asynchronous server errors. To channel them into the boundary, I will set some React Context that passes down a function that can be invoked at any moment to trigger an error. I'll refer to this function as forceError.

// ErrorBoundaryContext.tsx import { createContext, useContext } from "react"; export type ErrorBoundaryContextType = { forceError: (error: Error) => void; }; export const ErrorBoundaryContext = createContext<ErrorBoundaryContextType | null>(null); export const useErrorBoundaryContext = () => { const context = useContext(ErrorBoundaryContext); if (!context) { throw new Error( "useErrorBoundaryContext must be used within a PartialErrorBoundary" ); } return context; };
// ErrorBoundaryContext.tsx import { createContext, useContext } from "react"; export type ErrorBoundaryContextType = { forceError: (error: Error) => void; }; export const ErrorBoundaryContext = createContext<ErrorBoundaryContextType | null>(null); export const useErrorBoundaryContext = () => { const context = useContext(ErrorBoundaryContext); if (!context) { throw new Error( "useErrorBoundaryContext must be used within a PartialErrorBoundary" ); } return context; };

Then, I will extend the previously defined ErrorBoundary component, by wrapping its return with the ErrorBoundaryContext.Provider component. This ensures that all of its children have access to the forceError function.

// ErrorBoundary.tsx import * as React from "react"; import { View, Text } from "react-native"; import { ErrorReporting } from "@services/error"; import { ErrorBoundaryContext } from "./ErrorBoundaryContext"; const defaultErrorElement = ( <View> <Text>The app was about to crash, but I got ya!</Text> </View> ); interface Props { fallback: React.ReactNode; children: React.ReactNode; } interface State { hasError: boolean; } class ErrorBoundary extends React.Component<Props, State> { state = { hasError: false, }; static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error, errorInfo) { // You can log the error to an error reporting service like Sentry ErrorReporting.logErrorToMyService(error, errorInfo); } forceError = (error: Error) => { ErrorReporting.logErrorToMyService(error); this.setState({ hasError: true, }); }; render() { if (this.state.hasError) { return this.props.fallback || defaultErrorElement; } return ( <ErrorBoundaryContext.Provider value={{ forceError: this.forceError }}> {this.props.children} </ErrorBoundaryContext.Provider> ); } } export default ErrorBoundary;
// ErrorBoundary.tsx import * as React from "react"; import { View, Text } from "react-native"; import { ErrorReporting } from "@services/error"; import { ErrorBoundaryContext } from "./ErrorBoundaryContext"; const defaultErrorElement = ( <View> <Text>The app was about to crash, but I got ya!</Text> </View> ); interface Props { fallback: React.ReactNode; children: React.ReactNode; } interface State { hasError: boolean; } class ErrorBoundary extends React.Component<Props, State> { state = { hasError: false, }; static getDerivedStateFromError() { return { hasError: true }; } componentDidCatch(error, errorInfo) { // You can log the error to an error reporting service like Sentry ErrorReporting.logErrorToMyService(error, errorInfo); } forceError = (error: Error) => { ErrorReporting.logErrorToMyService(error); this.setState({ hasError: true, }); }; render() { if (this.state.hasError) { return this.props.fallback || defaultErrorElement; } return ( <ErrorBoundaryContext.Provider value={{ forceError: this.forceError }}> {this.props.children} </ErrorBoundaryContext.Provider> ); } } export default ErrorBoundary;

Finally, let me bring your attention to the ServerBoundary component, which is the most important component of all.

First off, let's talk about the queryFn prop. If you've worked with the react-query library, you might recognize this. I deliberately used the same naming style to make it clear that this component doesn't care about the nitty-gritty details of how you fetch your data. Just provide a function that returns a promise, and you're all set!

When your component mounts, the queryFn fires up and sends the request. Plus, you've got forceError ready to go if the server decides to be difficult. It's your manual button for triggering the error boundary in case the server rejects your request.

The DataRenderer component does the heavy lifting when it comes to data fetching and rendering. You can't directly use useErrorBoundaryContext inside the ServerBoundary component because the hook would fall outside the Context Provider. This encapsulation lets you access the error boundary context without issues.

If everything goes smoothly, your children render prop will be called with fresh data. Now, you can happily show your content just the way you want it on your screen!

// ServerBoundary.tsx import React, { useEffect } from "react"; import { View, Text } from "react-native"; import { useErrorBoundaryContext } from "./ErrorBoundaryContext"; import ErrorBoundary from "./ErrorBoundary"; interface Props<T> { children: (data: T | undefined) => React.ReactNode; queryFn: (arg: (e: Error) => void) => Promise<T>; } function DataRenderer<T>({ queryFn, children }: Props<T>) { const [data, setData] = React.useState<T | undefined>(undefined); const { forceError } = useErrorBoundaryContext(); useEffect(() => { queryFn(forceError).then(setData); }, []); return <>{children(data)}</>; } function ServerBoundary<T>({ children, queryFn }: Props<T>) { return ( <ErrorBoundary fallback={ <View> <Text>Something went wrong!</Text> </View> } > <DataRenderer queryFn={queryFn}>{children}</DataRenderer> </ErrorBoundary> ); } export default ServerBoundary;
// ServerBoundary.tsx import React, { useEffect } from "react"; import { View, Text } from "react-native"; import { useErrorBoundaryContext } from "./ErrorBoundaryContext"; import ErrorBoundary from "./ErrorBoundary"; interface Props<T> { children: (data: T | undefined) => React.ReactNode; queryFn: (arg: (e: Error) => void) => Promise<T>; } function DataRenderer<T>({ queryFn, children }: Props<T>) { const [data, setData] = React.useState<T | undefined>(undefined); const { forceError } = useErrorBoundaryContext(); useEffect(() => { queryFn(forceError).then(setData); }, []); return <>{children(data)}</>;} function ServerBoundary<T>({ children, queryFn }: Props<T>) { return ( <ErrorBoundary fallback={ <View> <Text>Something went wrong!</Text> </View> } > <DataRenderer queryFn={queryFn}>{children}</DataRenderer> </ErrorBoundary> ); } export default ServerBoundary;

Having illustrated all building blocks, let's take a look at an application that's rocking the ServerBoundary component.

// App.tsx import { FlatList, Text, View } from "react-native"; import ServerBoundary from "./ServerBoundary"; type Movie = { id: string; title: string; releaseYear: string; }; const fetchMovies = async (onError: (e: Error) => void): Promise<Movie[]> => { return fetch("https://reactnative.dev/movies.json") .then((res) => res.json()) .then((data) => data.movies) .catch(onError); }; const App = () => { return ( <View style={{ flex: 1, paddingTop: 48, paddingHorizontal: 24 }}> <ServerBoundary queryFn={fetchMovies}> {(data) => ( <FlatList data={data} keyExtractor={({ id }) => id} renderItem={({ item }) => ( <Text> {item.title}, {item.releaseYear} </Text> )} /> )} </ServerBoundary> </View> ); }; export default App;
// App.tsx import { FlatList, Text, View } from "react-native"; import ServerBoundary from "./ServerBoundary"; type Movie = { id: string; title: string; releaseYear: string; }; const fetchMovies = async (onError: (e: Error) => void): Promise<Movie[]> => { return fetch("https://reactnative.dev/movies.json") .then((res) => res.json()) .then((data) => data.movies) .catch(onError);}; const App = () => { return ( <View style={{ flex: 1, paddingTop: 48, paddingHorizontal: 24 }}> <ServerBoundary queryFn={fetchMovies}> {(data) => ( <FlatList data={data} keyExtractor={({ id }) => id} renderItem={({ item }) => ( <Text> {item.title}, {item.releaseYear} </Text> )} /> )} </ServerBoundary> </View> ); }; export default App;

The magic of using generics in the implementation really shines through in this example. Check out how the data in the render prop is properly typed – in this case, it's Movie[] | undefined.

You are more than welcome to also play with the Snack attached below.

Ideas for Improvement

Let's talk about some cool ways you could make things even better:

  • Distinguishing different error types: For instance, if you get a 5xx code from the server, how about showing a friendly "Retry" button? On the other hand, when you face 4xx errors or rendering exceptions, you might want to treat them as non-recoverable errors.
  • Adding Error Metadata: This way, you'll always know the origin of the error when it shows up in the error report.
  • Using react-query Like a Pro: The folks at react-query have crafted a game-changer solution to handle server state adequately. That way, you can get rid of both the local React state and the useEffect hook, and leverage the magical useQuery instead.
  • Incorporating a consolidated library: Instead of reinventing the wheel, you could grab the ErrorBoundary component from this super cool library. It's got some nifty features that I left out in my version for simplicity's sake.

Conclusion

Error boundaries in React Native act like safety nets for your app. They catch rendering errors and save you from crashes. However, they do have their limits – they only work during rendering, lifecycle methods, and constructors. Moreover, there's not much guidance out there, especially for React Native mobile apps.

This article aims to be a complete guide, offering a categorized approach to error boundaries, and implementation tips when needed. If you're into quick takeaways, here they are:

  • Top-Level Boundaries wrap your whole app but can be a bit user-unfriendly.
  • Feature/Navigator boundaries are placed in strategic regions with a "go back" option.
  • Server Boundaries bring all those async/server errors under one roof.

Let's make things smoother and error-proof together! 🚀

Raul Gomez

Written by Raúl Gómez Acuña

Raúl is a Product Engineer based in Madrid, Spain. He specialises in building mobile apps with React Native and TypeScript, while also sharing all his knowledge and experiences through the React Native University platform.

Back to Blog

* We aim to release new free content once a week. Be the first one to know!

React Native University • © 2024