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',
},
});
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.
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!
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
.
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.
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.
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,
},
});
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 theNavigator
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!