← Projects

React Native Multi-Root Architecture

Deprecated

The rearchitecture that unblocked React Native feature development on the Echo Look app — multi-root packages, a real build system, and standards for state, persistence, and navigation.

amazonreact nativearchitectureiosandroidbuild systems

By late 2017 the Echo Look app had reached a deadlock on React Native. Every React Native feature, however unrelated it was to the others, lived in one package under a single giant root view, and so each new feature inherited the problems of every existing feature, and 2 features could not be built in parallel without brutal merge conflicts. There were no standards for state management, and none for data persistence, and none for navigation. And the build system had no way to consume JavaScript bundles at all, because the bundles were copied and pasted by hand into the iOS and Android native packages and then checked in.

Most of the team considered it a lost cause. I made the case that it was solvable, and then I solved it, and that work made me the React Native architect for the Echo Look app.

The core decision: one root view or many

The immediate question was how to add a second React Native feature, which was the social feed that became Echo Look Style Feed, alongside the feature that already existed.

Option 1 was to expand the single root view so that it encapsulated the new feature. It keeps one entry point, and that looks simple enough. But it forces every future feature to share the same Redux store and the same navigation solution, and it requires store initialization even for trivial features, and it tightly couples all of the new work to the existing Gallery architecture.

Option 2 was to isolate each feature in its own root view, with its own state, and its own internal navigation, and its own sub-components, and then to move the genuinely shared components out into a common package.

For a green-field app where the React Native root is the app root, option 1 is defensible. Echo Look used React Native at the feature level inside a native app, and that makes option 2 the only real choice. I deliberately framed it as avoiding a one-way door, because we needed to make the right architectural call for the new feature without being blocked first on refactoring the old one.

Package structure

There were 3 kinds of package, and there were clear rules about what belonged in each of them:

  • Common, which holds the shared and utility components, agnostic of state management strategy and of navigation strategy, so that they can be reused anywhere at all.
  • Feature, which is one package per feature, declaring its own dependencies and owning its own internal navigation, and its own feature-level state, and its own persistence. A feature package only contains the components that render inside its root view, and anything rendered outside of that root view is by definition a different feature and gets a root of its own.
  • App, which registers every root-view feature component and generates a single app bundle, integrating with iOS and with Android across one bridge.
Commonshared, strategy-agnosticFeatureown state, navigation,persistence, depsFeatureone root view eachFeatureadded without touchingthe othersIntegrationone bundle, one bridge
Arrows point at dependencies. A new feature is a new box in the middle row — it does not touch its siblings.

Standards for state, persistence, and navigation

The architecture was only half of the work, and the other half was writing down the rules that the team could actually follow.

State lives at exactly one of 3 levels. Component state goes through the React lifecycle, or through a plain instance variable when it shouldn't trigger a re-render, and it is never shared with anything. State that is shared between components inside a feature belongs in that feature's Redux store. State that is shared across features belongs in native state, and is reached through the bridge. The governing principle is that state lives in a single place and is queried rather than cached, so that there is one source of truth for it.

ComponentReact lifecycle, or a plain instance variable when it shouldn't re-render. Never shared.FeatureThe feature's own Redux store. Shared between components inside one feature.NativeShared across features, reached through the bridge. Custom get, set, and change events.
State lives at exactly one level, and is queried rather than cached — so there is one source of truth.

Persistence follows the same shape, and it comes with caveats that are worth writing down. Anything that persists data owns the job of cleaning that data up at logout, and AsyncStorage is globally writable, so you do not stomp on another feature's keys, and AsyncStorage has real performance problems on Android because it runs on a single-threaded AsyncTask, and that applies transitively to Redux-Persist and to anything else built on top of it.

Navigation was constrained by the fact that the app was natively navigated, and so React Native navigation is used strictly within a feature. The feature-to-feature transitions and the feature-to-native transitions all go through native navigation instead.

Build system

Alongside the architecture I rewrote the iOS build system and the Android build system, so that the JavaScript bundles were generated and consumed automatically as part of the native app builds, which eliminated the hand-copied bundles and the human error that came along with them. I then fought through JavaScript package dependency resolution to get Jest and ESLint running inside the build pipelines, so that unit-test failures and lint violations would fail the build, and the team went from zero coverage and zero coding standards to enforced ones.

The same build approach was reused for the React Native at Amazon conference app.

What it unlocked

The multi-root architecture let us build the Style Feed as a separate package, in parallel with the continued maintenance of the existing gallery feature, and it let us integrate a partner team's React Native WebSocket library as their first non-Shopping-app consumer, which was something that had only been hypothetically possible before then. Updates to one feature stopped causing regressions in the other one, and QA regression testing could then be scoped to the feature that had actually changed.

The coding standards and the test frameworks outlived the app itself, and they carried forward into the Style Hub work and into the team's later Shopping-app features.