react-navigation Mistake #1: Not Explicitly Navigating Through Nested Navigators

Note: this is Part 1 of a series on common react-navigation mistakes and how to avoid them. I'll add links to subsequent posts as I publish them.

This mistake is easy to make in apps with a more complex route setup. Consider this simplified example:

1import React from "react";
2import { View, Text, TouchableOpacity } from "react-native";
3import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
4import {
5  NativeStackScreenProps,
6  createNativeStackNavigator,
7  } from "@react-navigation/native-stack";
8import Ionicons from "@expo/vector-icons/Ionicons";
9import { routes } from "./routes";
10import MyTabBar from "./TabBar";
11import ActionableCell from "../../components/ActionableCell";
12import ContactsScreen from "../../components/ContactList";
13
14type Props = NativeStackScreenProps<any, any>;
15
16const MoreScreen: React.FC<Props> = ({ navigation }) => {
17  return (
18    <View>
19      <ActionableCell
20        title={"Settings"}
21        onPress={() => navigation.navigate(routes.settings)}
22      />
23      <ActionableCell title={"Account"} onPress={() => {}} />
24    </View>
25  );
26};
27
28const SettingsScreen: React.FC<Props> = () => {
29  return (
30    <View>
31      <Text>Settings</Text>
32    </View>
33  );
34};
35
36const ContactsNavigator = createNativeStackNavigator();
37const ContactsStack = () => (
38  <ContactsNavigator.Navigator>
39    <ContactsNavigator.Screen
40      name={routes.contactsStack}
41      component={ContactsScreen}
42      options={({ navigation }) => {
43        return {
44          headerRight: () => (
45            <TouchableOpacity
46              onPress={() => navigation.navigate(routes.settings)}
47            >
48              <Ionicons name="settings" size={30} color="black" />
49            </TouchableOpacity>
50          ),
51        };
52      }}
53    />
54  </ContactsNavigator.Navigator>
55);
56
57const MoreNavigator = createNativeStackNavigator();
58const MoreStack = () => (
59  <MoreNavigator.Navigator>
60    <MoreNavigator.Screen name={routes.more} component={MoreScreen} />
61    <MoreNavigator.Screen name={routes.settings} component={SettingsScreen} />
62  </MoreNavigator.Navigator>
63);
64
65const BottomTabs = createBottomTabNavigator();
66const Navigation = () => (
67  <BottomTabs.Navigator
68    screenOptions={{
69      headerShown: false,
70    }}
71    tabBar={(props) => <MyTabBar {...props} />}
72  >
73    <BottomTabs.Screen name={routes.contacts} component={ContactsStack} />
74    <BottomTabs.Screen name={routes.moreStack} component={MoreStack} />
75  </BottomTabs.Navigator>
76);
77
78export default Navigation;

Our root navigator is a bottom tab navigator. Each tab contains a stack navigator. The first, our ContactsStack component, just renders a single Contacts screen. The second, MoreStack is a stack navigator with two screens: More and Settings.

In the header of our ContactsStack, we have a shortcut button to navigate us directly to our settings screen that calls navigation.navigate(routes.settings). Watch what happens when we press that button after we first launch the app.

We get an error:

The action 'NAVIGATE' with payload {"name":"settings"} was not handled by any navigator.

But what happens when we navigate to the Settings screen manually through the nav stack first, then try our "shortcut" button on the contacts screen?

It works, but why do we have to visit the Settings screen by navigating manually through the More Stack before we can navigate to it directly to it from the Contacts screen? Our code clearly lays out the structure of our navigators. Shouldn't react-navigation be able to find the screen?

We'll answer the question soon, but first let's take a quick peek into the internals of react-navigation to understand what it "sees" when the app is in the two different states above. To do this we'll use the handy navigation.getState() method which returns the state of the navigator it's called in. Let's do this in our root bottom tab navigator, inside our TabBar component, and log the result. This is what the navigation state looks like when the app first launches:

1{
2 "stale": false,
3 "type": "tab",
4 "key": "tab-X8FG6eJl-SnQ622esUyls",
5 "index": 0,
6 "routeNames": [
7  "contacts",
8  "moreStack"
9 ],
10 "history": [
11  {
12   "type": "route",
13   "key": "contacts-TLn0KSpSjMRX1N5mk7oRB"
14  }
15 ],
16 "routes": [
17  {
18   "name": "contacts",
19   "key": "contacts-TLn0KSpSjMRX1N5mk7oRB"
20  },
21  {
22   "name": "moreStack",
23   "key": "moreStack-0KDKYaoqteKnI7QgRlnpV"
24  }
25 ]
26}

Now let's compare that to what the state looks like after we've manually navigated to the Settings screen:

1{
2 "stale": false,
3 "type": "tab",
4 "key": "tab-X8FG6eJl-SnQ622esUyls",
5 "index": 1,
6 "routeNames": [
7  "contacts",
8  "moreStack"
9 ],
10 "history": [
11  {
12   "type": "route",
13   "key": "contacts-TLn0KSpSjMRX1N5mk7oRB"
14  },
15  {
16   "type": "route",
17   "key": "moreStack-0KDKYaoqteKnI7QgRlnpV"
18  }
19 ],
20 "routes": [
21  {
22   "name": "contacts",
23   "key": "contacts-TLn0KSpSjMRX1N5mk7oRB"
24  },
25  {
26   "name": "moreStack",
27   "key": "moreStack-0KDKYaoqteKnI7QgRlnpV",
28   "state": {
29    "stale": false,
30    "type": "stack",
31    "key": "stack-e8OhhqqVXotN9ecAC3wS-",
32    "index": 1,
33    "routeNames": [
34     "more",
35     "settings"
36    ],
37    "routes": [
38     {
39      "key": "more-BTtRFx5L9Ma6FxVhjvae5",
40      "name": "more"
41     },
42     {
43      "key": "settings-QVID5KNGujpDMy8_A0x86",
44      "name": "settings"
45     }
46    ]
47   }
48  }
49 ]
50}

Now our moreStack route has its own state property, with its own routes and routeNames array. This is the key difference between the initial state of the app that gives us the error, and the state after we've navigated through our "More" stack. In the inital state, the route we're trying to navigate to doesn't exist in the state tree. We have to navigate to the MoreStack navigator before it does.

If you've dealt with this problem before in React Native, then you know the solution is to explicitly navigate through the nested navigator. We can do this by replacing the existing code on line 21 above with this: navigation.navigate(routes.more, {screen: routes.settings}). This tells React Navigation to navigate to the MoreStack navigator first, and then to the Settings screen.

It might seem a little tedious to have to explicitly navigate through navigator after navigator to reach your destination screen, but that's just the nature of react-navigation's "dynamic configuration" setup. This passage from the docs explains it very well:

This may look very different from the way navigation used to work with nested screens previously. The difference is that in the previous versions, all configuration was static, so React Navigation could statically find the list of all the navigators and their screens by recursing into nested configurations. But with dynamic configuration, React Navigation doesn't know which screens are available and where until the navigator containing the screen renders. Normally, a screen doesn't render its contents until you navigate to it, so the configuration of navigators which haven't rendered is not yet available. This makes it necessary to specify the hierarchy you're navigating to. This is also why you should have as little nesting of navigators as possible to keep your code simpler.

- React Navigation Docs: Navigating to a Screen in a Nested Navigator

If you long for a simpler way to set up navigation, I have good news: v7 of react-navigation looks like it will support static navigation. That release is still in alpha at the time of writing, but it's worth paying attention to.