Fixing common performance problems in React Navigation

profile picture

Dries Engineer

20 Feb 2018  ·  6 min read


An exciting project in React Native for one of Belgium’s finest soccer teams turned into a great opportunity to fix some React Navigation performance issues. Let us show you how we did it.

Recently, we launched the first version of the app we are building for RSCA, one of Belgium’s leading soccer teams. The app - dubbed “RSCA Fan Engagement Platform” - lets fans consume exclusive content, engage in a live feed of a match, take pictures with custom filters and much more.

We set out to build the app in React Native for both Android and iOS. In this blog post we’d like to focus on the navigation in the RSCA-app, which gave us a headache at times.

First, a look at the current state of React Native

React Native has long been on our radar. We’ve used it in some of our products and found it had great potential. But we’d been holding back from adopting it in big projects, because we felt this young technology first needed to prove itself at scale. But then, in the past year, a lot of things changed in the React Native realm. React Native and a lot of third-party libraries have become more stable, and new libraries are being created every day.

The perfect moment to get our first major project in React Native on the road.

Now let’s cut to the chase… navigation!

For the RSCA-app, we decided to use the React Navigation library. We won’t go into details as to why we chose this library from the herd of navigation solutions – here’s a great post written on this topic!

We started by running a few tests with React Navigation: adding the navigation state to Redux, pushing new screens onto a stack, handling deeplinks, … It all worked without a hitch! So, we cleaned up the tests and added the rest of the screens.

Here is when things got a little less smooth. On a few screens – specifically those with lots of components – we started noticing a few things…

Right off the bat, there is a substantial delay between the user pressing a button and the swipe-in animation of a new screen. When a new screen is pushed, React Navigation will initially render it off-screen and animate it into place afterwards. This means that when a complex screen with lots of components that easily takes a few hundred milliseconds to render is pushed, it feels less snappy than a natively written application. It also causes some nasty side effects: for instance, if you tap a button quickly, you can trigger the same route from being pushed multiple times.

Another problem is that business logic can be executed while the swipe-in animation is doing its thing. This can make for a janky animation. This issue is caused by one of the most major downsides of React Navigation, and React Native in general. JS-accelerated animations and business logic run on the same thread: the JavaScript thread. In fact, React Native only has one thread where all work needs to be done. So when complex JS-accelerated animations are executed in combination with business logic, the JS-thread will drop frames, which causes a janky animation.

Of course, we only want perfect, buttery smooth animations! So how did we work with these shortcomings?

In our RSCA app, most of the screens pushed onto the stack need to fetch data, so an action is dispatched to the store and the corresponding saga starts an API-call. So to solve our issues with navigation and animations, we need to find a way to delay the fetching of new data until after the swipe-in animation completes. Turns out, React Native has an API for checking animations. InteractionManager provides a handy callback that will be fired after all animations are done, allowing us to only start fetching new data after our swipe-in animation. Let’s take a look at how this would be implemented:

// @flow

import React, { Component } from 'react';
import { InteractionManager } from 'react-native';

type Props = {
 dispatchTeamFetchStart: Function,
};

class Team extends Component<Props> {
 // Lifecycle methods
 componentDidMount() {
   // 1: Component is mounted off-screen
   InteractionManager.runAfterInteractions(() => {
     // 2: Component is done animating
     // 3: Start fetching the team
     this.props.dispatchTeamFetchStart();
   });
 }

 // Render
 render() {
   //  Render the navigation bar and a list of players
   ...
 }
}

export default Team;

With this fix, the animation doesn’t jank anymore – wonderful!

Well, almost.

Unfortunately, fixing this issue causes deeplinks to break – not so wonderful.

When handling a deeplink, the screen will be displayed without an animation, so there will be no animations to handle and the runAfterInteractions callback is never called when the component mounts. Fortunately, this can be easily fixed.

The next step is to reduce the duration of the initial off-screen render. We can do this by leveraging the same callback InteractionManager provides. On the initial render, we try to show as little data as possible to make sure the swipe-in animation starts right away.

Let’s take a look at the team list for example:

We start by loading the navigation bar and activity indicator. When the animation-end callback on InteractionManager is triggered, we update the view to show cached data and dispatch a start fetching action to the store. So now, when we tap a button the animation will start immediately – without delays!

Let’s take the previous code example and extend it…

// @flow

import React, { Component } from 'react';
import { InteractionManager } from 'react-native';

type State = {|
 didFinishInitialAnimation: boolean,
|};

type Props = {
 dispatchTeamFetchStart: Function,
};

class Team extends Component<Props, State> {
 constructor(props: Props) {
   super(props);

   // 1: set didFinishInitialAnimation to false
   // This will render only the navigation bar and activity indicator
   this.state = {
     didFinishInitialAnimation: false,
   };
 }

 // Lifecycle methods
 componentDidMount() {
   // 1: Component is mounted off-screen
   InteractionManager.runAfterInteractions(() => {
     // 2: Component is done animating
     // 3: Start fetching the team
     this.props.dispatchTeamFetchStart();
    
     // 4: set didFinishInitialAnimation to false
     // This will render the navigation bar and a list of players
     this.setState({
       didFinishInitialAnimation: true,
     });
   });
 }

 // Render
 render() {
   // When didFinishInitialAnimation is false
   //  - render just the navigation bar and activity indicator
   // When didFinishInitialAnimation is true
   //  - render the navigation bar and a list of players
   ...
 }
}

export default Team;

With all that done, there’s one more issue to fix: we can still push the same route by quickly tapping a button multiple times. Luckily for us, the React Native community has already found a solution for this. By adding a so called debouncer to our Redux Middleware, we can cancel actions with the same route, if they are triggered multiple times within a specified timeframe.

To conclude…

By introducing these optimisations we notably improved the navigation’s animations. Of course, you don’t have to add these optimisations to every screen in your app. What we did was go through the app, pinpoint inefficiencies and optimize the screens that really needed it.

So go ahead and give it a try!