Build A JavaScript Navigator for React Native

A step-by-step guide to building your own navigator in JavaScript for React Native

Build a JavaScript Navigator for React Native

Navigation is a hot, and often contested, topic in React Native. It's something nearly every app has, multiple solutions exists, and they each have their pros and cons.

There are great solutions out there (React Navigation and React Native Navigation are my top choices) but I think building a navigator is a great exercise to further your understanding of React Native. It forces you to design an API, work with animations, handle gestures, and more.

So that's what we'll do today. We'll build a basic JavaScript navigator for React Native.

Requirements

We're going to build a navigator that allows us to keep a stack of cards. It should

  • have a simple declarative API
  • allow us to push new screens onto the stack
  • pop the current screen off the stack and go to the previous screen
  • animate between screen transitions
  • handle user gestures for swiping back (covered in part 2)

Getting Started

I'll be using create-react-native-app to create my project. You can run the following in your terminal

create-react-native-app rn-js-navigator

You can then run the app on an iOS or Android simulator with yarn run ios or yarn run android, respectively.

API Design

The navigator will have one Navigator component with which we wrap all of the valid screens. Each screen we want to register with the navigator will be passed in a Route component. Like this:

<Navigator>
  <Route name="Screen1" component={Screen1} />
  <Route name="Screen2" component={Screen2} />
  <Route name="Screen3" component={Screen3} />
</Navigator>

The top level Navigator component is where all the actual work happens. The Route component allows us to pass various properties/configuration down for each screen - in this case a name (which will be used to specify which screen should be pushed) and a component (which component should actually be rendered).

Each route will get a navigator prop passed to it and on that navigator prop a push and pop function will be on it. Allowing for the following type of interaction:

const Screen2 = ({ navigator }) => (
  <View style={[styles.screen, { backgroundColor: '#23395B' }]}>
    <Button
      title="Screen 3"
      onPress={() => navigator.push('Screen3')}
    />
    <Button
      title="Pop"
      onPress={() => navigator.pop()}
    />
  </View>
);

Alright, with the basic API outlined, lets get to writing some code.

Boilerplate

First, let's create the three screens we'll use in our app. In App.js replace the file with the following

App.js

import React from 'react';
import { StyleSheet, View, Button } from 'react-native';

const Screen1 = ({ navigator }) => (
  <View style={[styles.screen, { backgroundColor: '#59C9A5' }]}>
    <Button
      title="Screen 2"
      onPress={() => navigator.push('Screen2')}
    />
    <Button
      title="Pop"
      onPress={() => navigator.pop()}
    />
  </View>
);

const Screen2 = ({ navigator }) => (
  <View style={[styles.screen, { backgroundColor: '#23395B' }]}>
    <Button
      title="Screen 3"
      onPress={() => navigator.push('Screen3')}
    />
    <Button
      title="Pop"
      onPress={() => navigator.pop()}
    />
  </View>
);

const Screen3 = ({ navigator }) => (
  <View style={[styles.screen, { backgroundColor: '#B9E3C6' }]}>
    <Button
      title="Pop"
      onPress={() => navigator.pop()}
    />
  </View>
);

export default Screen1;

const styles = StyleSheet.create({
  screen: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
});

Boilerplate

Now, let's create a Navigator.js file in which all of our navigation logic will live. Right now it's just going to be a skeleton of the exported components.

Navigator.js

import React from 'react';

export const Route = () => null;

export class Navigator extends React.Component {
  render() {
    return null;
  }
}

Now, lets go back to App.js and use these new components to define our routes.

First we need to import our components.

App.js

import { Navigator, Route } from './Navigator';

The replace

export default Screen1;

with

App.js

export default class App extends React.Component {
  render() {
    return (
      <Navigator>
        <Route name="Screen1" component={Screen1} />
        <Route name="Screen2" component={Screen2} />
        <Route name="Screen3" component={Screen3} />
      </Navigator>
    );
  }
}

If you see a blank white screen you're exactly where you should be!

Rendering Screens

All of our screens are accessible via this.props.children in the Navigator component. You could render the first screen via

Navigator.js

export class Navigator extends React.Component {
  render() {
    const CurrentScene = this.props.children[0].props.component;
    return <CurrentScene />;
  }
}

which should now show the green Screen1 again. This works, but accessing everything via this.props.children won't work very well going forward. We're going to need some internal state.

First, we'll store a stack array which will track the current stack of rendered screens. It should default to the first child of Navigator. We're also going to create an easier to use sceneConfig object that will allow us to access all of the data we need quickly when pushing a new screen onto the stack.

First we'll create a buildSceneConfig function that accepts the Navigator children as an argument.

Navigator.js

const buildSceneConfig = (children = []) => {
  const config = {};

  children.forEach(child => {
    config[child.props.name] = { key: child.props.name, component: child.props.component };
  });

  return config;
};

Inside of this function we'll populate an object that represents our sceneConfig. This results in the following data.

{
  Scene1: {
    key: 'Scene1',
    component: Scene1,
  },
  Scene2: {
    key: 'Scene2',
    component: Scene2,
  },
  Scene2: {
    key: 'Scene2',
    component: Scene2,
  },
}

We can then use this function in the constructor of the Navigator component and store the result in state. We can also populate our stack with the first screen.

Navigator.js

export class Navigator extends React.Component {
  constructor(props) {
    super(props);

    const sceneConfig = buildSceneConfig(props.children);
    const initialSceneName = props.children[0].props.name;

    this.state = {
      sceneConfig,
      stack: [sceneConfig[initialSceneName]],
    };
  }

  // ...
}

We can then use this.state.stack to render our screen.

Navigator.js

export class Navigator extends React.Component {
  constructor(props) {
    super(props);

    const sceneConfig = buildSceneConfig(props.children);
    const initialSceneName = props.children[0].props.name;

    this.state = {
      sceneConfig,
      stack: [sceneConfig[initialSceneName]],
    };
  }

  render() {
    const CurrentScene = this.state.stack[0].component;
    return <CurrentScene />;
  }
}

Push Action

If you were to press "Screen 2" at this point the app will error with Cannot read property 'push' of undefined.

That's because we aren't yet passing a navigator prop down to the scene. In this navigator prop we'll pass the push action. Let's write that push handler now.

Navigator.js

export class Navigator extends React.Component {
  constructor(props) { ... }

  handlePush = (sceneName) => {
    this.setState(state => ({
      ...state,
      stack: [...state.stack, state.sceneConfig[sceneName]],
    }));
  }

  render() { ... }
}

All we're doing here is accepting a sceneName, which should correspond to a name prop given to one of our Route components and then finding the corresponding scene config for that route and adding it to the stack.

We then need to make the push function available to the current scene.

Navigator.js

export class Navigator extends React.Component {
  constructor(props) { ... }

  handlePush = (sceneName) => {
    this.setState(state => ({
      ...state,
      stack: [...state.stack, state.sceneConfig[sceneName]],
    }));
  }

  render() {
    const CurrentScene = this.state.stack[0].component;
    return <CurrentScene navigator={{ push: this.handlePush }} />;
  }
}

If you press "Screen 2" now no error occurs! But also nothing changes, despite the state change. Let's fix that.

To do so we'll need to loop over this.state.stack and render the screens (we'll take care of styling later).

First you'll need to import some components from React Native.

Navigator.js

import { View, StyleSheet } from 'react-native';

We'll then loop over this.state.stack and render each scene. We'll also set up some styling for the container view.

Navigator.js

export class Navigator extends React.Component {
  // ...

  render() {
    return (
      <View style={styles.container}>
        {this.state.stack.map((scene, index) => {
          const CurrentScene = scene.component;
          return (
            <CurrentScene
              key={scene.key}
              navigator={{ push: this.handlePush }}
            />
          );
        })}
      </View>
    )
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: 'row',
  },
});

When you press a screen name you should now see something like this.

Basic Push Action

Pop Action

Before we fix the styling, let's add a pop action.

Navigator.js

export class Navigator extends React.Component {
  // ...

  handlePop = () => {
    this.setState(state => {
      const { stack } = state;
      if (stack.length > 1) {
        return {
          stack: stack.slice(0, stack.length - 1),
        };
      }

      return state;
    });
  }

  render() {
    return (
      <View style={styles.container}>
        {this.state.stack.map((scene, index) => {
          const CurrentScene = scene.component;
          return (
            <CurrentScene,
              key={scene.key}
              navigator={{ push: this.handlePush, pop: this.handlePop }}
            />
          );
        })}
      </View>
    )
  }
}

In the handlePop function we're checking if the stack has more than one screen and, if so, we remove the last screen in that stack.

Make sure you pass the pop function down on the navigator prop!

Basic Pop Action

Styling

To ensure screens show up on top of each other we'll use absolute positioning. StyleSheet.absoluteFillObject is a nice short hand option for this.

Navigator.js

export class Navigator extends React.Component {
  // ...

  render() {
    return (
      <View style={styles.container}>
        {this.state.stack.map((scene, index) => {
          const CurrentScene = scene.component;
          return (
            <View key={scene.key} style={styles.scene}>
              <CurrentScene
                navigator={{ push: this.handlePush, pop: this.handlePop }}
              />
            </View>
          );
        })}
      </View>
    )
  }
}

const styles = StyleSheet.create({
  // ...
  scene: {
    ...StyleSheet.absoluteFillObject,
    flex: 1,
  },
});

Notice that we're wrapping CurrentScreen component in a View component to apply the styles. We also need a flex: 1 so the view takes up the entire screen. Notice also that the key moved from CurrentScene to the View.

Styling Fix

Animation

Alright, final thing for this tutorial. The animation! We're going to do a right-to-left animation, as is typical on iOS.

First let's take care of a few modules we need to import.

Navigator.js

import { View, StyleSheet, Animated, Dimensions } from 'react-native';

const { width } = Dimensions.get('window');

We need Animated to manage animations efficiently and Dimensions so we can get the width of the screen.

We'll then intialize a new _animatedValue on the component to drive the slide animation.

Navigator.js

export class Navigator extends React.Component {
  // ...

  _animatedValue = new Animated.Value(0);

  // ...
}

Now that the boilerplate is setup, let's add the right-to-left animation when you push a new screen onto the stack.

It's important to do this at the right time. The right time in this case is after we've updated the state. That means we'll start the animation in the setState callback.

Navigator.js

export class Navigator extends React.Component {
  // ...

  handlePush = (sceneName) => {
    this.setState(state => ({
      ...state,
      stack: [...state.stack, state.sceneConfig[sceneName]],
    }), () => {
      this._animatedValue.setValue(width);
      Animated.timing(this._animatedValue, {
        toValue: 0,
        duration: 250,
        useNativeDriver: true,
      }).start();
    });
  }

  // ...
}

First we set the _animatedValue to the width of the screen. That's what the starting offset will be. If you wanted it offset to the left then it would be -width.

Once we set that value we actually do the animation. This animation brings the offset to 0 or fully visible on the screen. The duration I set is an arbitrary value.

Finally, notice that I'm using useNativeDriver. This is an important thing to do when working with Animations in React Native as it will provide better performance of your animations and reduce the likelihood of "jitter" in your animations.

Now that we're setting the values correctly we need to apply them, which will happen in the render function.

Navigator.js

export class Navigator extends React.Component {
  // ...

  render() {
    return (
      <View style={styles.container}>
        {this.state.stack.map((scene, index) => {
          const CurrentScene = scene.component;
          // Create an array of styles for the scene
          const sceneStyles = [styles.scene];

          // If we're on the last screen and there's more than one screen in the stack then animation makes sense.
          if (index === this.state.stack.length - 1 && index > 0) {
            sceneStyles.push({
              transform: [
                {
                  translateX: this._animatedValue,
                }
              ]
            });
          }

          // Convert the View to Animated.View
          return (
            <Animated.View key={scene.key} style={sceneStyles}>
              <CurrentScene
                navigator={{ push: this.handlePush, pop: this.handlePop }}
              />
            </Animated.View>
          );
        })}
      </View>
    )
  }
}

Okay, there's a bit going on here. First off we're setting up a sceneStyles array for the containing scene view as the styles can be different between screens.

We then determine whether a screen should be animated. An animation only makes sense when there is more than 1 screen in the stack. We also only want to apply the styles to the active/last screen.

We then use a transform style, where we target the translateX value, to apply the this._animatedValue value. When you use useNativeDriver you're limited on which values you can modify - transform props are one of them!

Finally, we moved from View to Animated.View and passed the sceneStyles to the style prop.

That leaves us with the following.

Push Animation

Woo! Progress. Now the pop action.

This time we want to run our animation before we update state because we only want to remove the screen from state after it's off screen. Make sense?

Navigator.js

export class Navigator extends React.Component {
  // ...

  handlePop = () => {
    Animated.timing(this._animatedValue, {
      toValue: width,
      duration: 250,
      useNativeDriver: true,
    }).start(() => {
      this._animatedValue.setValue(0);
      this.setState(state => {
        const { stack } = state;
        if (stack.length > 1) {
          return {
            stack: stack.slice(0, stack.length - 1),
          };
        }

        return state;
      });
    });
  }

  // ...
}

First we'll make our offset go to the screen width. Once the animation is complete (which we know by the callback in the .start function being called) we need to make sure to reset our offset to 0 so that the new active screen is fully visible.

Pop Animation

Final Code

The final code for Navigator.js:

Navigator.js

import React from 'react';
import { View, StyleSheet, Animated, Dimensions } from 'react-native';

const { width } = Dimensions.get('window');

export const Route = () => null;

const buildSceneConfig = (children = []) => {
  const config = {};

  children.forEach(child => {
    config[child.props.name] = { key: child.props.name, component: child.props.component };
  });

  return config;
};

export class Navigator extends React.Component {
  constructor(props) {
    super(props);

    const sceneConfig = buildSceneConfig(props.children);
    const initialSceneName = props.children[0].props.name;

    this.state = {
      sceneConfig,
      stack: [sceneConfig[initialSceneName]],
    };
  }

  _animatedValue = new Animated.Value(0);

  handlePush = (sceneName) => {
    this.setState(state => ({
      ...state,
      stack: [...state.stack, state.sceneConfig[sceneName]],
    }), () => {
      this._animatedValue.setValue(width);
      Animated.timing(this._animatedValue, {
        toValue: 0,
        duration: 250,
        useNativeDriver: true,
      }).start();
    });
  }

  handlePop = () => {
    Animated.timing(this._animatedValue, {
      toValue: width,
      duration: 250,
      useNativeDriver: true,
    }).start(() => {
      this._animatedValue.setValue(0);
      this.setState(state => {
        const { stack } = state;
        if (stack.length > 1) {
          return {
            stack: stack.slice(0, stack.length - 1),
          };
        }

        return state;
      });
    });
  }

  render() {
    return (
      <View style={styles.container}>
        {this.state.stack.map((scene, index) => {
          const CurrentScene = scene.component;
          const sceneStyles = [styles.scene];

          if (index === this.state.stack.length - 1 && index > 0) {
            sceneStyles.push({
              transform: [
                {
                  translateX: this._animatedValue,
                }
              ]
            });
          }

          return (
            <Animated.View key={scene.key} style={sceneStyles}>
              <CurrentScene
                navigator={{ push: this.handlePush, pop: this.handlePop }}
              />
            </Animated.View>
          );
        })}
      </View>
    )
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    flexDirection: 'row',
  },
  scene: {
    ...StyleSheet.absoluteFillObject,
    flex: 1,
  },
});

Complete Example

Additional Challenges

Looking to further work on this example? Here's a few additional things you can work on implementing to further your experience.

  • Add an overlay/shadow on the previous screen to give the appearance that the screen is behind, like iOS does
  • Allow the consumer to set an initial screen via an initialSceneName prop rather than forcing it to be the first screen registered in the Navigator component.
  • Set a backgroundColor prop to allow the consumer to set the background color for any transparent components.
  • Allow the consumer to swap direction of animation for right-to-left languages
  • Add a swipe back gesture (interested in learning this? Check out my blog post on how to do so!)

Thanks for reading and I hope you found this exercise valuable! Whenever I want to better understanding something, or just want a code challenge, I try to find something I use often and start rebuilding it from scratch. Not only can it help you better understand what's going on but it can also help you contribute back to the open source solution you typically use!

My name is Spencer Carli - I teach people to use React Native to make their product vision a reality through online tutorials and consulting. Looking for more tutorials? Checkout my courses, tutorials, or Youtube channel!


Spencer Carli

Spencer teaches people to use React Native to make their product vision a reality through online courses, tutorials, and consulting. He can be reached at handlebarlabs.com

You can find his other works on Medium, YouTube, or watch his Free React Native Course.