React Native Multi-Root Architecture
DeprecatedThe 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.
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.
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.
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.