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 oronChangeText
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 definecomponentDidCatch
, 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;
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;
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.
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;
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;
// 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;
// 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.
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 thecatch()
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;
};
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;
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;
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;
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 theuseEffect
hook, and leverage the magicaluseQuery
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! 🚀