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.
We are going to start our journey from this 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)
++pre>++code class="language-javascript">
/* navigation/ChildNavigatorOne.types.tsx */
export type ChildNavigatorOneStackParamList = {
ScreenOne: undefined;
ScreenTwo: undefined;
};
++/pre>++/code>
++pre>++code class="language-javascript">
/* 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>
++pre>++code class="language-javascript">
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.
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:
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-javascript">
/* 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:
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.
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-javascript">
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-javascript">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:
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-javascript">
/* 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:
++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:
Let’s take a step back and analyze the place of our navigation in our 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.
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:
How can we create such a separation of concerns with React Navigation?
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-javascript">
/* 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-javascript">
/* 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-javascript">
/* 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!
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
Screens
Features
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!