Routing and Navigation in React Native

Note: this is part of my upcoming Programming React Native book. Enjoy!

Why navigation

You might ask yourself: why do you need navigation? What's a navigation stack? Well, navigation on mobile apps covers these concerns:

  • Situational awareness: know where you're at, and possibly, where you came from
  • Functional: be able to navigate. Go back, undo, skip, or "deep link" into some functionality of the app
  • Infrastructural: consolidate ceremonies that need to be done before rendering a new screen.
  • Maintainability: to realize the above concern, often, you'll need to build a state machine. A navigation stack is a state machine. Platforms may let you code this state machine declaratively or visually, and promote maintainability by keeping all of that logic in one place.

Why Navigation is Scary

In my opinion, one of the scariest topics in mobile application development is routing and navigation.

Navigation infrastructure typically holds a navigation stack, which holds screens, which hold components and data. Often, this state is not represented anywhere; it is a transient state that the user have created by merely navigating around, which is why it makes it hard to reason about.

Navigation flows are also hard to reproduce, should you bump into bugs in that area, and often these bugs carry memory leaks and the sorts (we did just mention a navigator stack holds reference to screens, which hold reference to components, which holds reference to data, and so on).

Navigation UX deals with how each operating system brought with it its own truth as to how to properly do navigation, as well as how to facilitate it: Android's implicit back button behavior, and iOS with its purely explicit text navigation bar.

And lastly, each platform has its own tooling and how to ease up all said pains. Android has the traditional approach, where you build screens and code transitions with the omnipotent Intent, and lately iOS offered a more intuitive approach with Storyboards and almost physically connecting screens.

Navigation in React Native

The bad news is that navigation doesn't get less scary to me, even with React Native. I guess that for me the saying "the more you know, the more you worry" painfully applies.

However, the good news, or maybe the great news is - that with the React, Javascript, and React Native combo everything becomes amazingly more predictable.

  • React sports one-way binding, which helps us reason about our code. So to find memory leaks we travel a one-directional dependency tree (YMMV).
  • Javascript brings about the Chrome Devtools, which by now are one of the most powerful toolsets for developers. If you're going to build something complex, you're lucky to be living in 2015 (at least, that's when this book was written, consider yourself luckier if you're from the future!).
  • React Native gives us plenty of escape hatches. This is the case where we want to use NavigatorIOS, the native iOS navigation stack, in order to minimize the element of surprise by not emulating a navigation stack, or simply put - sticking to the host OS best practices.

In addition, if what you're building is really painfully complex (years of effort and complexity), I guess at this stage you probably don't really need this book and should build your app on each platform separately using dedicated tooling and approaches, just to be safe.

Let's take a look at the two React Native navigation solutions.

Navigator vs. NavigatorIOS

React Native started on iOS, and this is why the best-in-class or perhaps most used navigation stack is NavigatorIOS. If you're building an iOS only app and want to play it safe, use it. However, if a year or more passed from the writing of the book, it might be possible that the generic Navigator dwarfed NavigatorIOS in features and stability, so allow yourself to evaluate that again by Googling around.

Navigator is React Native's attempt to abstract the concept of navigation. At the time of writing, it is good-but-not-great a bet for a smooth ride if you want to keep a common navigation codebase for iOS and Android.

We'll use NavigatorIOS for iOS and Navigator for Android. Even if reality changes and Navigator becomes the be-all-end-all navigation solution for both platforms, I would want to keep it apart. It feels that navigation on iOS and Android will always want to be different, somehow, either UX or hardware-wise, so it makes sense to future-proof this and make room for code to evolve differently.

Navigator

Let's go through a brief Navigator example. For this, we'll assume the common master-detail pattern, where we have a master view containing a list of items, and then when tapping an item we want to navigate to a child (or detail) item.

Wiring Navigator

This is what you probably wanted to read before I filled your head with back button craze.

The Navigator is a React component that deals with two main concerns:

  1. Returning a properly configured Navigator component, with icon, back icon, colors, initial route and more. In addition, it should wire a renderScene handler, which we talk about next.

  2. A renderScene handler implementation, which is a function that takes care of mapping a route (which just a Javascript object) to a screen of our choosing. The goal is similar in concept to backend routing with frameworks like Express or Rails, which deals with separating routing from destination.

Here is how we set that up, for a simple two-screen ("first" to "second") navigation flow:

class Navigation extends React.Component{
  render() {
    return (
      <Navigator
        style={styles.container}
        initialRoute=\{\{id: 'first'}}
        renderScene={this.navigatorRenderScene}/>
    );
  }

  navigatorRenderScene(route, navigator) {
    _navigator = navigator;
    switch (route.id) {
      case 'first':
        return (<First navigator={navigator} title="first"/>);
      case 'second':
        return (<Second navigator={navigator} title="second" />);
    }
  }
}

In the render function, we set up a Navigator, in which the most important properties to specify are the initialRoute and renderScene. Note that a route is simply any Javascript object that will be carried from screen to screen. Here, we make a convention to have our main routing concern exist behind an id property; if we have anything else to pass, we'll use a suitable payload, within the same object. However, let's agree that id will always be reserved for routing.

The good news is that we passed the clunky part of declaring a Navigator component and then wiring up screens. It can become less clunky, maybe, some day. It can have some sort of a DSLish feel to it such as:

// This doesn't really exist, but you can make it
var Navigation = routes({
  'first': (route, navigator)=>{
      <First title="foobar">
  }
})

But this kind of experience doesn't exist yet. It's quite a low-hanging fruit, so think of it as a nice weekend project to do :)

Next up, our screens are really normal views that take care of massaging a ToolbarAndroid to their needs. A ToolbarAndroid is our top navigation bar, and with React Native, it is quite flexible. So flexible, in fact, that we need to code every decision point such as when to show the back icon, what kind of title to display and so on, based on our current and past screens.

A ToolbarAndroid is also the real Android Toolbar widget (see here) which, in accordance to our theme here (showing the platform-specific way first) is an Android-specific component.

Flexibility can be good and bad. Good since on each screen, we can specify exactly how we want things to be had on the navbar. Bad, because we need to be defensive here since it can become a maintenance overhead or a state machine of the worst kind - the one that is spread out through out our entire codebase.

Here is our Second screen:

class Second extends React.Component{
  render() {
    return (
      <View style={styles.container}>
        <ToolbarAndroid style={styles.toolbar}
                        title={this.props.title}
                        navIcon={require('image!ic_arrow_back_white_24dp')}
                        onIconClicked={this.props.navigator.pop}
                        titleColor={'#FFFFFF'}/>
        <Text>
          Second screen
        </Text>
      </View>
    );
  }
};

If you're trying this out, make sure that ic_arrow_back_white_24dp is an icon you've dropped in your resources folder - in this case Android. For the sake of the experiment you can use a single hi-res image for all size variants.

Note here, that we specify our navIcon explicitly. We want users to be able to tap a back icon right on the navbar.

Next up, our First screen:

class First extends React.Component{
  navSecond(){
    this.props.navigator.push({
      id: 'second'
    })
  }
  render() {
    return (
      <View style={styles.container}>
        <ToolbarAndroid style={styles.toolbar}
                        title={this.props.title}
                        titleColor={'#FFFFFF'}/>
        <TouchableHighlight onPress={this.navSecond.bind(this)}>
          <Text>Navigate to second screen</Text>
        </TouchableHighlight>
      </View>
    );
  }
}

Here, we don't specify a back button, because we recognize it as the root screen. The most important (and fun) piece of code here is how we navigate to the Second screen. We just push a new object which happens to contain an id that we agreed is our routing property that we base our routing on. Note that we don't specify the screen type, object, or tag here - this is the essence of separating routing from destination or implementation, which is a Good Thing.

This completes our vanilla Navigator walkthrough. Next up we'll take a look at its older brother: NavigatorIOS.

On Android, we'll have to take care of navigation, and also the back button.

Android's Back Button

Tapping into the back button is unique to Android devices, in that it is on occasion a "hardware" button on a dedicated touch area of the glass as in Samsung phones or HTC phones, or a software button that renders at the lower part of the screen (common on the Nexus family of devices).

The following boilerplate is more or less needed verbatim in our app to support responding to the back button. Note that the _navigator variable is scope-global, and it gets filled on first navigation. First read this snippet of code to understand what's going on, and then I'd recommend tracking the _navigator variable throughout as well.

var {
  ...,
  ...,
  ...,
  BackAndroid
} = React;

var _navigator; // we fill this up upon on first navigation.

BackAndroid.addEventListener('hardwareBackPress', () => {
  if (_navigator.getCurrentRoutes().length === 1  ) {
     return false;
  }
  _navigator.pop();
  return true;
});

BackAndroid is a simple library that binds to the native events of the host device.

Deep dive alert!: here's how it works (notice the special exitApp case):

RCTDeviceEventEmitter.addListener(DEVICE_BACK_EVENT, function() {
  var invokeDefault = true;
  _backPressSubscriptions.forEach((subscription) => {
    if (subscription()) {
      invokeDefault = false;
    }
  });
  if (invokeDefault) {
    BackAndroid.exitApp();
  }
});

On iOS you need not worry if you're mistakenly (or if you lazily want to use the exact same code) using the BackAndroid module - all of the functions it carries are no-ops.

As with any event listener, when you add a listener, you must immediately ask yourself how you are going to remove it - does the subscribe method return a special handle you need to provide when canceling?, or do you have to do the bookkeeping yourself and come up with the same reference to the handler you provided?

In the BackAndroid case, when we subscribe we must keep a reference to the handler function we give it. However, do we really see ourselves disabling the back button in real life?

ToolbarAndroid and Navigator.NavigationBar

It is important to notice that Navigator comes with a pre-baked piece of UI that functions as your top Android navigation bar. If you'll dig into UIExplorer within the facebook/react-native Github repo, you'll see these kinds of things:

var NavigationBarRouteMapper = {

  LeftButton: function(route, navigator, index, navState) {
    if (index === 0) {
      return null;
    }

    var previousRoute = navState.routeStack[index - 1];
    return (
      ...
    );
  },

  RightButton: function(route, navigator, index, navState) {
    return (
      ...
    );
  },

  Title: function(route, navigator, index, navState) {
    return (
      ...
    );
  },

};

That is a concept of a NavigationBarRouteMapper or in other words, you're telling your Navigator how to compose the NavigationBar's UI on each and every route.

Then, you stick an actual NavigationBar into your Navigator this way:

<Navigator
      ...
      navigationBar={
        <Navigator.NavigationBar
          routeMapper={NavigationBarRouteMapper}
        />
      }

So what you've got here, is a Navigator which we learned how to configure and tell it how to render itself as a response to a given route, and a NavigationBar that pretty much does the same.

Immediately, you start thinking - why separate the two? Your NavigationBar and Navigator rendering is spread out through two different mappers, and also, why not make a route a first-class citizen and making itself render? something like ShowCartRoute.render(), and then ShowCartRoute.renderBar()? well, this is what Exponentjs/ex-navigator solves exactly, and more or less feels like. I recommend checking it out. Also, you might imagine that this is not going to be the only flavor people would like to do their routing with, so keep watching out for new things.

Should you use a ToolbarAndroid or a Navigator's own NavigationBar? The answer is again - tradeoff. The NavigationBar is strongly tied to routes, ToolbarAndroid is tied to your view. ToolbarAndroid is, well, Android, and you could probably implement such a thing yourself generically.

So bottom line, if you're implementing something simple, then go with iOS's NavigatorIOS for iOS (coming in the next section), and ToolbarAndroid for Android. Otherwise, use a Navigator for both, and either NavigationBar, or your own piece of bar that you construct manually. And, of course, do a quick browse on my awesome-react-native list for any fancier bar that you'd like.

NavigatorIOS

This navigator is a bit simpler, and relies on the native iOS navigation stack. The iOS navigation stack is a powerful beast, it is only encouraging that it is hidden under such a simple React Native component, however be sure that if we wanted to do more involved things (custom Segues and such) it might have become trickier.

For now, let's implement the same Navigator example with NavigatorIOS.

class Navigation extends React.Component{
  render() {
    return (
      <NavigatorIOS
        style={styles.container}
        initialRoute=\{\{
          title: 'first',
          component: First
        }} />
    );
  }
}

Our Navigation component is simple and explicit, and the initial route specifies the verbatim component (here First) that we want to run. As a side note, you might also like to call this component Router or Handler or anything that represents a concept of a routing shell component.

Next, we take a look at both our screens, First and Second.

class First extends React.Component{
  navSecond(){
    this.props.navigator.push({
        title: 'second',
        component: Second
    })
  }
  render() {
    return (
      <View style={styles.content}>
        <TouchableHighlight onPress={this.navSecond.bind(this)}>
          <Text>Navigate to second screen</Text>
        </TouchableHighlight>
      </View>
    );
  }

Somewhat similar in structure to Navigator, however again we see explicit mention of the Second screen.

Note that when we use a NavigatorIOS and a plain child View as in this case, we will need to handle the height of a typical iOS navigation bar. In other words, we need to add a paddingTop property to our View such that the content will be offset from under the navigation bar. On more advanced components, such as the ScrollView, we might want to look for the automaticallyAdjustContentInsets property that allows the component to handle this for us automatically.

The Second screen looks at least as simple:

class Second extends React.Component{
  render() {
    return (
      <View style={styles.container}>
        <Text>
          Second screen
        </Text>
      </View>
    );
  }
};

As mentioned during the Navigator overview, NavigatorIOS is simpler - we don't have any navigation bar to tweak here, and in this case the view is completely vanilla - reusable and clean as-is.

Passing Data

Often when doing navigation, in addition to the route, we want to pass an additional data, for the new screen-to-be to root on.

In the generic Navigator component, data is passed as part of the plain Javascript object (in this example, the data property), so we can do something like this:

    switch (route.id) {
      case 'first':
        return (<First navigator={navigator} data={route.data} title="first"/>);

With NavigationBar it will mostly be the same, if you take care to pass your data within your routeMapper route object, and picking it apart in your routeMapper callbacks.

With NavigatorIOS we need to use the special passProps property:

  <NavigatorIOS
      initialRoute=\{\{
        component: First,
        title: 'first',
        passProps: { data: 'some-data' },
      }}
    />

And the receiving component will get both the data property and a special route and navigator props it can use to make decisions and to navigate further.

There is a fascinating question about when a new screen is born due to a new navigation. Is the data that the screen just got from the route a pointer to data it needs to fetch?, or the actual data it needed to fetch verbatim?. No answer is wrong: it is a trade-off between being implicit and explicit, or defensive rather than naive, respectively.

Patterns

Search in Navbar

  • NavigatorIOS - Can't do this.
  • Navigator, with Navigator.NavigationBar - make sure that your NavigationBar mapper will render your variant of a title like this:
  Title: function(route, navigator, index, navState) {
    return (
      <AwesomeSearchbar .../>
    )
  • Navigator and ToolbarAndroid - make sure that the screen you are routing to, which is supposed to contain the search bar inlined within the navbar, should now render a single child which is your searchbar, prefer not providing a title in this case (styling and others are cut for brevity):
<ToolbarAndroid>
  <AwesomeSearchbar .../>
</ToolbarAndroid>

A searchbar is interactive - it is completely OK to make the AwesomeSearchbar component interactive by supplying callbacks via props. You can pull these callbacks in two ways:

  1. NavigatorBar renderer - from your route, or by making the renderer take parameters.
  2. ToolbarAndroid - since it is rendered within a container view, simply the callbacks of the neareset smart component will do.

Custom Content in Title

As in the previous pattern, the idea is similar sans the callbacks.

  • NavigatorIOS - content is string only
  • NavigatorBar and ToolbarAndroid - as with the previous pattern, simply hand over the component you want to render, this time no callbacks or interactivity needed.

Routed Navbar Content

We saw this while handling NavigatorBar mapper - the content can change as a function of the route you are not traveling into.

  • NavigatorIOS - each time you push a navigation, you can define how the coming-to-be navbar will look like, so this is a simple matter of using a new title:
this.props.navigator.push({
      title: "some new title",
      ...
      })
  • Navigator and NavigationBar - use the route mapper, as we've seen before.
  • ToolbarAndroid - this is trivial since you're rendering it as part of the view, so you can couple the rendering to the view itself. Meaning, don't fuss with the route, but with the actual view component content.

Reactive Navbar Content

There comes a time where your navbar changes, but not as a reaction to route changes, but to some kind of an external event, timed event, or anything reactive in nature.

The way to solve it right now, which is gaining consensus, is to inject an event emitter to your app flows, and make sure that components on that navbar know to use it.

  • NavigatorIOS - again, not possible
  • Navigator.NavigationBar and ToolbarAndroid - make sure that the content you give each, will be able to use your global event emitter:
<ToolbarAndroid>
  <AwesomeSearchbar emitter={this.emitter} .../>
</ToolbarAndroid>

You can also not use an explicit emitter but a Flux dispatcher, or do use an explicit emitter and inject it via something similar to React's context, an so on. Since this is an advanced/thought material topic (under the "lightbulb" category) I'll leave it to you for exploration.

Getting Input

If you want to just go to a screen to collect input (i.e. modals), you can either use the actual Modal component (see here), or send off a view with a callback within the renderScene logic block. At extreme cases you can use event emitter, or if you're using Flux than a Flux action trivially solves it.

Spreading Props

If we scratch our head for a moment, we remember that routing in Navigator is done with a plain Javascript object. We can actually be opinionated and define this object as such:

{
  id: 'route-id'
  props: {
    some: 'props'
  }
}

And this way we can then do something like this, within our renderScene:

  navigatorRenderScene(route, navigator) {
    _navigator = navigator;
    return (<First navigator={navigator}
                   {...route.props}/>);
  }

Using the new spread operator ..., we easily inline the entire props bag of properties into our component. Remember React will go left-to-right on various props so you can enjoy a cascading effect (provide defaults and then override them with specific data)

Summary

This concludes our discussion about routing. In summary, we've learned the following:

  • Routing is hard, however at least on mobile, we have less rope to hang ourselves with by adopting the best practices of each mobile platform.
  • React Native offers the generic and flexible Navigator for a general case use, and the older NavigatorIOS for iOS specific work. Choose the latter if you don't need flexibility and want to run fast by getting the default iOS navigation stack behavior.
  • Routing is either defined implicitly with Navigator and routes, or explicitly with NavigatorIOS and the specific components we route to.
  • Remember to compensate for various UI glitches when using a navbar. Some content may vanish under the navbar because it is not padded.
  • Passing data is easy through both Navigator and NavigatorIOS.
  • NavigationBar is a common to both concept, it has its own tradeoffs.