How we improved app performance and code quality by upgrading React Navigation

A deep dive into how Opendoor upgraded our most critical third-party library and improved performance without rewriting our entire application

Tyler Deitz
Open House

--

Authors note: This article was written right before React Navigation version 6 was shipped, but luckily every topic we cover related to version 5 still applies 100% to React Navigation today!

Background

In 2016, we introduced the Opendoor app. As a 100% React Native application, we were very early movers to the ecosystem, shipping an app a little over a year after React Native’s initial release. At the core of the application is React Navigation, a library that binds the application experience together by exposing an API to construct navigators, a native development paradigm, in React. Constructing new navigators is an integral part of building out a new feature, especially if the feature is a user flow containing multiple “screens” that need to be transitioned between.

Because of this, our application began to become tightly coupled with React Navigation’s APIs, specifically the APIs in version 1, which was bleeding edge at the time we began using it. Although v1 was a landmark moment for the React Native ecosystem, the improvements promised by future versions of the library quickly began to age our codebase.

Some of the most desired improvements by our team were the out-of-the-box performance improvements. Pretty soon after React Navigation v1 was released, the engineering organization Software Mansion began working on an exciting new project called React Native Screens, which allows developers to render native iOS/Android view controllers as normal React components. In v2.14.0, React Navigation integrated this with its built-in navigators and posted the following documentation:

Prior to react-navigation@2.14.0, all screens are essentially regular native View components in each platform, which will increase memory usage and make the render tree deep in a heavy-stacked application. This is one of the reasons your app is slowing down compared to a native navigation solution.

…By using react-native-screens, it is possible for each native platform to optimize the memory usage for screens that are under the view stack and also simplify the native node hierarchy.

React Navigation: Optimize memory usage and performance

Another huge issue with v1 was its lack of TypeScript types. At Opendoor, we are all-in on TypeScript and are constantly yielding value from maintaining a strongly typed frontend. React Navigation not being typed was a big elephant in the room, especially when you consider just how fundamental a navigation framework is in the context of a mature React Native application.

Failed v4 upgrade attempt and v5 discovery

My coworker Nate Grossman and I first attempted to upgrade our app to v4 during an Opendoor Hack Week back in 2019. Unfortunately, we hit some pretty big blockers — the biggest one being that React Navigation had switched to a static and global navigation API design, compared to v1 which was static but not global. This meant we needed to define a single static navigation system upfront for our application to render. This was devastatingly problematic for our application architecture because we had relied on the v1 implementation detail of distinct navigators having no knowledge of each other to colocate small, feature-bounded navigators with their container React components.

This issue made the v4 upgrade a nonstarter due to it being an entire app rewrite, but it at least yielded a scrappy proof of concept that put the possibility into a lot of people’s minds. As our codebase grew in size, engineers began to clamor for the automatic performance gains a newer version would bring us. However, it just wasn’t in the cards to completely rebuild the application from the ground up with v4’s architecture, especially while the app was in constant development.

This issue also hinted at a larger issue we were facing: what was our app going to look like when a new version of React ships with API changes that break React Navigation v1? Were we going to maintain a fork that keeps React 19, 20, 21 compatibility? How much time would we need to sink into this package that was already half a decade old?

With these tangible performance grievances in mind as well as the looming threat of future incompatibility, our team began looking for a long-term solution to the navigation issue. Although it was tempting to write off the v4 upgrade attempt as a failure, in the end it clarified what the essentials of a successful upgrade would look like and where the difficult parts were. Our instinct told us that a large enough API change could potentially open an upgrade path in the future. It’s here that we began researching React Navigation v5, specifically the conference talk Component First Navigation In React Native by maintainers Satyajit Sahoo and Michał Osadnik.

React Navigation v5’s biggest architecture change was switching from a static and global navigation system to a dynamic and global navigation system. This means that once again just like in v1, the building-blocks of a navigation system became the composition of simple React components. This was much more congruous with our application structure, and amazingly provided a way forward that completely bypassed the issues discovered in the v4 upgrade attempt.

Mapping a v5 upgrade path

Now that React Navigation had dropped its static and global architecture, we needed to account for the rest of the API changes that would clash with our v1 app. We began researching and ended up discovering two rather large changes that remained to be accounted for:

1. A global route namespace

In React Navigation v1, navigators are completely isolated and have no knowledge of each other. In v5, React Navigation builds a global store of every route that can be navigated to, based on the components that are currently being rendered. This means that you can navigate to any route in the entire app from any screen, which introduces the problem of name clashing between navigators. If one navigator has a route named “Root,” and another navigator has a route named “Root,” attempting to navigate to “Root” will produce nondeterministic results.

2. The removal of screenProps

React Navigation v1 had a feature called “screenProps” our team used heavily, which was essentially a way of injecting the same set of props into every screen of a navigator. However, v5 made a very good call to completely drop this as a navigation feature with the argument being that React already has many many ways to accomplish this task (Redux, context, the list goes on…) and this complexity shouldn’t be tacked on to the core service React Navigation provides.

Upgrade process

Finally, with all changes accounted for, we began the upgrade process with these goals in mind:

  1. Make as few code changes as possible
  2. Split the upgrade up into as many separate PRs as possible
  3. Integrate with v5’s TypeScript types
  4. Get our app to a state where we can ship v5 navigators coexisting alongside legacy v1 navigators (The most aspirational and important one of all)

Porting screenProps to routeProps

Our first step was accounting for the removal of screenProps. This one was easy because we could simply restructure our data injection to eliminate our usage of the deprecated API — no real ties to navigation. We also wanted to simultaneously fix a small naming annoyance in our codebase and change the term “screenProps” to “routeProps,” so variables like “HomeScreenScreenProps” could be renamed to a less awkward “HomeScreenRouteProps.”

The solution was to wrap every navigator with a “routeProps” context provider that would emit an object of the same exact shape that the screenProps API had previously provided. Then every screen component would simply have to switch from using the screenProps prop to consuming the routeProps context value.

Here’s an example of a navigator with screenProps (before):

And an example of a navigator with routeProps (after):

Maintaining v1 compatibility

With screenProps accounted for, our team then began tackling the meat of the project: porting navigators over to v5. The constraint of having both v5 and v1 navigators coexisting in our app made this task even more daunting.

The first step towards this goal was to clone React Navigation v1 into a new repo and change its package name from “react-navigation” to “react-navigation-v1.” After that we went about namespacing every v1 utility in our codebase, while also documenting them as deprecated in TypeScript for further clarity.

The next step was to visualize what it would actually look like to upgrade an existing v1 screen to v5. Consider this pattern we used in v1: In some cases, passing down the navigation prop of a “Screen” component into child components makes a lot of sense, especially in a v1 context where navigators are completely isolated from each other and sometimes you need to perform a navigation action on a parent navigator. However, what if that screen is now a v5 screen with a completely new API? Passing that down to a child component expecting a v1 navigation prop would completely break things.

In the spirit of making as few code changes as possible, rewriting every child component was too far-reaching for us. Plus, in many cases, the child components were rendering in multiple areas of our app that were still v1, so maintaining v1 compatibility was a functional necessity. We landed on a solution to create a “v5 to v1” conversion function that we could call when needed.

Example of this solution, which we ended up calling “createNavigationV1Proxy:”

The full implementation of this solution can be found here: navigationV1Proxy.ts.

Top-down navigator conversion

Our app (like most apps) is structured like this: If you consider an application being a tree of navigators, then the largest and most-used navigators tend to reside somewhere near the top, while smaller “feature-bounded” navigators reside lower down the tree.

Shipping the performance improvements as fast as possible was a top priority for our team. This means that it made the most sense to start the conversion at the root of this tree and continue down from there.

Model of our app’s navigation tree

This reasoning fits well with a constraint we have with our v5/v1 hybrid system: There can never be a v1 navigator nested between a pair of v5 navigators. Since React Navigation v5 can navigate to any route from any navigator, it needs to have control of all navigators all the way up to the route being navigated to. Adding a foreign v1 navigator in between this connection would produce nondeterministic results, as React Navigation v5 would have no idea what the v1 navigator’s current state is and have no ability to control it.

Correct and incorrect models of a hybrid navigation tree

After converting just a few main navigators, we estimated that we had already put roughly 85% of the gains in our users’ hands. To be honest, some of our lesser-used navigators are still v1 (and that’s totally fine)! We already have a guaranteed stable way to convert v5 navigation props to v1, so there’s no rush to upgrade everything over in one fell swoop. This incremental approach delivered value to our users at a magnitude-level faster than a rewrite approach would have.

Global route system

Because v5 has a global route namespace, we decided that the best approach to organizing our v5 routes would be to create one big “Routes” object two levels deep: the first level to access the name of the navigator and the second to access the name of the screen. The final value would be a string that follows a “NavigatorName.RouteName” naming convention. This makes it so route names can be simple, while avoiding global collision. We decided to call this type of object a “namespaced enum.”

Don’t forget that we also wanted a robust type system as part of this upgrade. To achieve this, we needed to have our route names exist as both static TypeScript types as well as actual runtime values.

So the question was: How can we build a namespaced enum such that the return type is a literal reflection of the returned enum value itself? To do this, we needed to pull in two brand-new (at the time) TypeScript features: Template Literal Types and Variadic Tuple Types.

After some research, we discovered that it was possible to infer an array argument as a literal tuple type using the Variadic Tuple Types feature, a feature that allows you to declare tuple types of an unknown length. This unlocked the ability to declare a navigator’s route names as an array of strings, while still having reference to the literal string values statically as types:

We paired this discovery with the Template Literal Types feature, a new feature that allows you to constrain literal strings to a defined shape. This unlocked the ability to reference our route naming convention as an actual type (“NavigatorName.RouteName”) if we could infer both “NavigatorName” and “RouteName” as unions of literal strings passed in as arguments.

An example of the Template Literal Types feature:

The final API we landed on is a function called “createNamespacedEnum.” Here’s what it looks like to use:

createNamespacedEnum is one of the most complex type declarations in our entire codebase. In fact, it almost felt like this function needed to be written twice — once as a TypeScript type and once as an actual runtime function!

Let’s take a look at what kind of function definition can generate a type like this:

The full implementation can be found here: createNamespacedEnum.ts.

TypeScript integration

React navigation’s type system centers around the concept of a “ParamList.” A ParamList accomplishes two things: (1) it describes the route names intended for a navigator and (2) describes the navigation parameters intended for each route.

Example ParamList:

A ParamList is the main input value for almost all of React Navigation’s APIs. Knowing this, the way forward with our type integration was to create a ParamList type generator that builds upon our namespaced enum routing system.

To do this, we first created a global ParamList type and colocated it right next to our global Routes object. This meant that every param needed for every screen was defined right in one well-known place. The final ParamList type takes one generic argument, the name of a navigator (defaulting to all navigators), and picks from the global ParamList all the routes that are defined under that navigator.

Here’s how it looks to use:

A full implementation of this type can be found here: ParamList.ts.

Results

After porting just the few main navigators, our team saw a 20% drop in CPU usage across the board in our performance test suite. However, the real gains were discovered after utilizing some of the new v5 APIs like useIsFocused to conditionally render expensive UI. In the end we were able to fine-tune some of our worst-performing metrics up to around a 70% drop in CPU usage. This resulted in a whopping ~40% more listing pages viewed per session!

Integrating with v5’s type system also helped us find dozens of bugs in our code, while acting as guideposts to help onboard other engineers into building with v5. With the new global routing system, we were also able to audit by reference equality that around 10% of screens in our codebase were completely inaccessible in our app — the remnants of old features never fully removed. This resulted in a huge wave of code deletion that we otherwise wouldn’t have had the confidence to perform without a stricter audit.

Conclusion

Just a few months ago, our biggest navigators used to be simple View components with a few animations and transforms added on. (This is the way v1 worked internally to mimic native navigators.) Now, I’m excited to say that all core navigators are powered under the hood by iOS native UINavigationControllers completely off the JS thread, using the new Native Stack Navigator API. This was a pretty incredible result for our team, especially considering that this transition caused no user-facing changes, and shipped with no critical bugs during the entire process, with no documented migration strategies for our unprecedented approach.

Looking back, the top-down v5/v1 hybrid system ended up being a near-perfect balance of engineering complexity and end-user benefit. Especially when we realized that it’s “probably fine” to keep edge-case v1 navigators around now that we have a robust proxy system to interop between the two versions. Also, the hybrid system has created an opening to empower other developers to start porting features they own over to v5 themselves. This removes the potential of a knowledge silo forming within the original porting team.

Funnily enough, not upgrading before v5 was a good call in terms of engineering complexity. However, now that we’re back on track I’m sure we’ll be able to react to future API changes much faster and with better planning. Looking forward to React Navigation v6, it seems like the project is now long-term dedicated to the dynamic configuration system introduced in v5, which we at Opendoor are very happy to hear 🙂. Once a daunting and tedious aspect of our app, our navigation system is now an area of enablement for developers thanks to the upgrades we were able to ship.

Interested in joining our team?
Check out some of our open roles: Software Engineer, Sr. Software Engineer (backend), Sr. Software Engineer (full stack), Sr. Software Engineer (front end)

Want to learn more about working at Opendoor?
Check out our Design blog and recent Opendoor news + more employee spotlights.

--

--