React Native

Why you should not use useNavigation

Introduction

Navigation is the backbone of our mobile app’s UI. Many different patterns have emerged over the years, from bottom navigation bars to navigation drawers. Some of us even worked on no navigation principles.

React Navigation is the most popular navigation library in the React Native community. It supports most of the classic navigation patterns. We use this library in all our projects at BAM.

I’ve been using it for four years, since its V2. React Navigation has introduced many innovations over the years. In my opinion, the most important ones appeared in 2020, when they released the V4. We moved from a static to a dynamic way of handling navigators and screens. In addition, new hooks such as ++code>useRoute++/code> and ++code>useNavigation++/code> have improved the developer experience, making most mobile navigation patterns easy to implement.

However, the navigation in a complex app remains hard. I’ve seen many bugs coming from bad refactoring on navigation trees. I’ve analyzed their root causes for a while, and two culprits kept showing up: ++code>useNavigation++/code> and ++code>Typescript++/code>.

In this article, we’ll see what you should do and what you shouldn’t do when it comes to typing ++code>useNavigation++/code>. We’ll also take a step back and think about who’s responsible for the navigation in our architecture. Most of the examples have been implemented inside this repository. So feel free to clone it and play with it to challenge your comprehension of how React Navigation works. This article requires a certain knowledge regarding React Navigation and Typescript. If it’s not the case, you can start by reading the documentation.

Project Setup

We are going to start our journey from this navigation tree:

Navigation tree

We represent the navigators in blue, and the screens in red

You can test the app and look at the codebase by checking out this commit.

Let’s take ++code>ChildNavigatorOne++/code> as an example to see how we define a navigator using typescript. (the documentation is available here)

  • First, we need to define its list of screens with their associated navigation parameters. In our example, we are not going to use navigation parameters.

++pre>++code class="language-typescript">
/* navigation/ChildNavigatorOne.types.tsx */

export type ChildNavigatorOneStackParamList = {

 ScreenOne: undefined;

 ScreenTwo: undefined;

};
++/pre>++/code>

  • Then, we can create our navigator using the type ++code>ChildNavigatorOneStackParamList++/code> previously defined and our two screens, ++code>Screen1++/code> and ++code>Screen2++/code>.

++pre>++code class="language-typescript">
/* navigation/ChildNavigatorOne.tsx */

import { createStackNavigator } from "@react-navigation/stack";

import { ScreenOne } from "../screens/ScreenOne";
import { ScreenTwo } from "../screens/ScreenTwo";
import { ChildNavigatorOneStackParamList } from "./ChildNavigatorOne.types";

const Stack = createStackNavigator<ChildNavigatorOneStackParamList>();

export const ChildNavigatorOne = () => {
 return (
   <Stack.Navigator>
     <Stack.Screen
       name="ScreenOne"
       component={ScreenOne}
       options={{ headerTitle: "Screen One" }}
     />
     <Stack.Screen
       name="ScreenTwo"
       component={ScreenTwo}
       options={{ headerTitle: "Screen2" }}
     />
   </Stack.Navigator>
 );
};
++/pre> ++/code>

  • Finally, we can create the types used to type navigation and route props for ++code>Screen1++/code> and ++code>Screen2++/code>.

++pre>++code class="language-typescript">
import { CompositeScreenProps } from "@react-navigation/native";
import { StackScreenProps } from "@react-navigation/stack";
import { ChildNavigatorOneProps } from "./RootNavigator.types";

export type ChildNavigatorOneStackParamList = {
 ScreenOne: undefined;
 ScreenTwo: undefined;
};

export type ScreenOneProps = CompositeScreenProps<
 StackScreenProps<ChildNavigatorOneStackParamList, "ScreenOne">,
 ChildNavigatorOneProps
>;

export type ScreenTwoProps = StackScreenProps<
 ChildNavigatorOneStackParamList,
 "ScreenTwo"
>;
++/pre>++/code>

We will now implement some navigation requirements with ++code>useNavigation++/code>, and we’ll analyze multiple ways to type it.

Navigation with useNavigation

Type useNavigation with a type parameter

Now that our app is set up, let's look at the first feature we will implement with ++code>useNavigation++/code>.

My Product Owner asks me to implement a new user story:

++pre>++code>AAUser, when I’m on ScreenOne, when I click on a “go to Screen Two” button, ScreenTwo opens.++/pre>++/code>

Inside my component ++code>ScreenOne++/code>, I want to add a button that gives me the possibility to navigate to ++code>ScreenTwo++/code>. In an advanced project, this component would be nested so we decide to use ++code>useNavigation++/code> to avoid prop drilling issues as recommended in the documentation:

++code>UseNavigation++/code> is a hook which gives access to ++code>navigation++/code> object. It's useful when you cannot pass the ++code>navigation++/code>  prop into the component directly, or don't want to pass it in case of a deeply nested child.

and Its code could look like this:

++pre>++code class="language-typescript">
/* components/GoToScreenTwoButton.tsx */

import { useNavigation } from "@react-navigation/native";

import { Button } from "react-native";

export const GoToScreenTwoButton = () => {

 const navigation = useNavigation();

 return (

   <Button

     title="go to Screen Two"

     onPress={() => {

       navigation.navigate("ScreenTwo");

     }}

   />

 );

};++/pre>++/code>

However, If we use typescript in your project and check our types, we’ll get the following error:

As we do not give information to ++code>react-navigation++/code> on our navigation tree, It tells us that It can’t know if ++code>ScreenTwo++/code> is a valid destination.

The documentation tells us that it is possible to annotate ++code>useNavigation++/code> to give information about the navigation tree and our position. As my button belongs to ++code>ScreenOne++/code>, I can use its screen props type:

++pre>++code class="language-typescript">
/* navigation/ChildNavigatorOne.types.tsx */

export type ScreenOneProps = CompositeScreenProps<

 StackScreenProps<ChildNavigatorOneStackParamList, "ScreenOne">,

 ChildNavigatorOneProps

>;

/* components/GoToScreenTwoButton.tsx */

...

const navigation = useNavigation<ScreenOneProps["navigation"]>();

...
++/pre>++/code>

And the error vanishes!

My feature is fully functional. I can merge it and go to my next task!

After a while, my Product Owner comes with a new user story:

++code>++pre> AAUser,  when I’m on ScreenThree, when I click the “go to Screen Two” button, the ScreenTwo opens. ++/pre>++/code>


Awesome! I already have a component ++code>GoToScreenTwoButton++/code> that handles this logic. I can reuse it in my ++code>ScreenThree++/code> component. And…, It does not work.

++code>ScreenTwo++/code> is not directly accessible from ++code>ScreenThree++/code> because they do not belong to the same navigator. Unfortunately, Typescript is not able to protect us this time. Furthermore, the documentation warns us about that:

It's important to note that this isn't completely type-safe because the type parameter you use may not be correct and we cannot statically verify it.

What can I do to take advantage of static analysis tools such as type checking with ++code>tsc++/code> to be protected from invalid navigation?

We simplified this example on purpose. However, in more complex apps, I’ve seen this same pattern be responsible for undetected bugs. The complexity of the navigation tree and the reuse of such components reduce the capacity of the developer and the reviewer to catch errors. Automatic and manual testing can be good ways to find them. However we’ll focus on typescript and architectural guidelines for the rest of this article.

Type useNavigation through RootParamList

A better solution is to type the navigation through RootParamList. This solution has the advantage of providing safer typing. (the documentation is available here).

The ++code>RootParamList++/code> interface lets React Navigation know about the params accepted by your root navigator.

This type will be used by ++code>UseNavigation++/code> as a fallback if we do not pass any types.

Let’s configure it in our project:

++pre>++code class="language-typescript">
/* navigation/RootNavigator.types.tsx */
export type RootNavigatorStackParamList = {

 ChildNavigatorOne: NavigatorScreenParams<ChildNavigatorOneStackParamList>;

 ChildNavigatorTwo: NavigatorScreenParams<ChildNavigatorTwoStackParamList>;

};

/* navigation/react-navigation.types.d.ts */

import { RootNavigatorStackParamList } from "./RootNavigator.types";

declare global {

 namespace ReactNavigation {

   interface RootParamList extends RootNavigatorStackParamList {}

 }

}
++/pre>++/code>

We should also remove the annotation we did on ++code class="language-typescript">useNavigation++/code> as we saw it was not the best solution.

++pre>++code class="language-typescript">
import { useNavigation } from "@react-navigation/native";

import { Button } from "react-native";

export const GoToScreenTwoButton = () => {

 const navigation = useNavigation();

 return (

   <Button

     title="go to Screen Two"

     onPress={() => {

       navigation.navigate("ScreenTwo");

     }}

   />

 );

};++/pre>++/code>

We now have a new typescript error:

Because ++code>RootParamList++/code> is linked to our ++code>RootNavigator++/code>, we need to declare our navigation as if it was triggered from the ++code>RootNavigator++/code>.

++pre>++code class="language-typescript">import { useNavigation } from "@react-navigation/native";

import { Button } from "react-native";

export const GoToScreenTwoButton = () => {

 const navigation = useNavigation();

 return (

   <Button

     title="go to Screen Two"

     onPress={() => {

       navigation.navigate("ChildNavigatorOne", { screen: "ScreenTwo" });

     }}

   />

 );

};++/pre>++/code>

And it works! We can now navigate to ++code>ScreenTwo++/code>, both from ++code>ScreenOne++/code> and ++code>ScreenThree.++/code>

With this solution, typescript protects us from invalid navigations. However, in my opinion, there are two main drawbacks with this solution:

  • The navigation call implementation becomes more complicated as we nest navigators
  • Our components are coupled to our tree navigation implementation

Here’s an example. Let’s suppose we need to nest our ++code>ScreenThree++/code> and ++code>ScreenFour++/code> components inside a new navigator ++code>LeafNavigator++/code>. Our navigation tree looks now like this:

Once we implement our new navigator, we get a typescript error:

We used to navigate from ++code>ScreenThree++/code>to ++code>ScreenFour++/code>, and even if there are still siblings, our implementation of the navigation is now wrong. We need to make the following change:

++pre>++code class="language-typescript">
/* screens/ScreenThree.tsx (before) */

<Button

 title="go to Screen Four"

 onPress={() => {

   navigation.navigate("ChildNavigatorTwo", { screen: "ScreenFour" });

 }}

/>

/* screens/ScreenThree.tsx (after) */

<Button

 title="go to Screen Four"

 onPress={() => {

   navigation.navigate("ChildNavigatorTwo", {

     screen: "LeafNavigator",

     params: { screen: "ScreenFour" },

   });

 }}

/>
++/pre>++/code>

Every component that calls a navigation action needs to know exactly where it belongs inside the navigation tree.

As a consequence, any modification of the navigation tree will be followed by a modification of all navigation actions triggered from the screens impacted by the modification:

  • Some modifications are essential because they were broken by the modification.
  • The others are just consequences of the pattern we now use to implement our navigations.
  • In our example, the code to navigate to ++code>ScreenFour++/code>

++pre>++code class="language-typescript">
navigation.navigate("ChildNavigatorTwo", {

     screen: "LeafNavigator",

     params: { screen: "ScreenFour" },

   });++/pre>++/code>

could have been replaced by

++pre>++code>navigation.navigate("ScreenFour");++/pre>++/code>

Let’s summarize what we learned:

  • Typing our navigation by annotating ++code>useNavigation++/code> is error-prone. Typescript can’t protect us from wrong navigation targets. We should never do that.
  • Typing our navigation with ++code>RootParamList++/code> ensures no navigation errors while we keep our navigator parameters list up to date. However, the implementation is more complex because it’s coupled to the place of the component inside the navigation tree.

Let’s take a step back and analyze the place of our navigation in our architecture.

Navigation’s architecture

When I develop a new feature, I like to think about it as an autonomous component or set of components that don't know where they are displayed in the app.

Many business requirements can alter the navigation and composition of the screens without altering the features.

  • A feature displayed inside ++code>Screen1++/code> has to be moved to ++code>Screen2++/code>.
  • A feature displayed inside ++code>Screen1++/code> has to be displayed in ++code>Screen2++/code>.
  • A feature displayed on two separate screens (e.g., a form) has to be displayed inside only one screen to reduce user clicks.
  • A screen in a nested stack navigator has to be changed to a root modal.

If these changes impact your feature’s implementation, you may suffer from coupling between your navigation and your features. And this is precisely what useNavigation does.

Is it a problem ? On a simple app probably not. But the more your app complexifies, with multiple nested navigators, and hundreds of features, the more you’ll loose track about the dependencies between the navigation world and the features world.

A strict separation of concerns between these two worlds has multiple benefits:

  • You get a better maintainability in the long term.
  • It’s easier to change your navigation library. Let’s be honest, if you use React Navigation for your app, you’ll probably stay with it for the app's lifetime. However, It’s more and more frequent today to generate a web app from a React Native codebase thanks to React Native Web. Unfortunately, React Navigation support for the web is still experimental. You may prefer to rely on a framework like NextJS and its routing features instead.
  • It’s easier to test your features as you do not need to mock the navigation.

How can we create such a separation of concerns with React Navigation?

Navigation with navigation prop

To implement this separation between navigation and features, we have to declare all our navigation actions inside our screen and then pass them to our feature through props.

We could use ++code>useNavigation++/code> inside our screens, but our navigators already pass the navigation object to our screens, so let’s use that instead.

Let’s have a look at some changes:

++pre>++code class="language-typescript">
/* screens/ScreenThree.tsx (before) */

<Button

 title="go to Screen Four"

onPress={() => {

   navigation.navigate("ChildNavigatorTwo", {

     screen: "LeafNavigator",

     params: { screen: "ScreenFour" },

   });

 }}

/>

/* screens/ScreenThree.tsx (after) */

<Button

 title="go to Screen Four"

 onPress={() => {

   navigation.navigate("ScreenFour");

 }}

/>++/pre>++/code>

Navigation to siblings has been simplified. We no longer need to declare the navigation from the root navigator. We can take the shortest path available inside the navigation tree.

Our implementation of ++code>GoToScreenTwoButton++/code> has also been simplified:

++pre>++code class="language-typescript">
/* components/GoToScreenTwoButton.tsx (before) */

import { useNavigation } from "@react-navigation/native";

import { Button } from "react-native";

export const GoToScreenTwoButton = () => {

 const navigation = useNavigation();

 return (

   <Button

     title="go to Screen Two"

     onPress={() => {

       navigation.navigate("ChildNavigatorOne", { screen: "ScreenTwo" });

     }}

   />

 );

};

/* components/GoToScreenTwoButton.tsx (after) */

import { Button } from "react-native";

interface Props {

 onPress: () => void;

}

export const GoToScreenTwoButton = ({ onPress }: Props) => {

 return <Button title="go to Screen Two" onPress={onPress} />;

};++/pre>++/code>

It’s not coupled to the navigation tree anymore. It’s the responsibility of the screen where it’s called to implement the navigation action.

++pre>++code class="language-typescript">
/* screens/ScreenOne.tsx */

<GoToScreenTwoButton onPress={() => navigation.navigate("ScreenTwo")} />

/* screens/ScreenThree.tsx */

<GoToScreenTwoButton

       onPress={() =>

         navigation.navigate("ChildNavigatorOne", { screen: "ScreenTwo" })

       }

     />++/pre>++/code>

There is still one drawback with this implementation: prop drilling. If the component responsible for triggering the navigation is deeply nested, you’ll have to pass the callback to each intermediate components. For what I’ve seen in my projects, most of the time, it’s a non-problem if you mind flattening your component trees (e.g., with composition). For the rare cases where deep component nesting is mandatory, React Contexts are here to save your day!

Conclusion

We’ve seen that a correct typing of the navigation object is paramount to protect us from navigation errors.

If you already use ++code>useNavigation++/code> and do not type it with ++code>RootParamList++/code>, I advise you to prepare a plan and do the migration as soon as possible.

If you’re going to create a new React Native app from scratch, try using exclusively the navigation prop at the screen level. Here’s a summary of everyone’s responsibility:

Navigators

  • Declare a list of screens and sub-navigators to build the navigation tree.
  • Generate all the types for the screen (navigation & route props) so that they can know their places in the navigation tree and what navigation actions they can achieve.

Screens

  • Display one or more features
  • Declare the navigation actions for their children according to their place in the navigation tree. They use the navigation prop given by the navigator.

Features

  • Define the navigation callbacks they need as props and call them when needed.

Any counterarguments against these recommendations? I would be pleased to discuss it on Twitter. If you liked this article, you could find more of my content about React Native and Software Architecture on my blog!

Développeur Mobile ?

Rejoins nos équipes