Running React Native everywhere: Yarn Workspaces monorepo

TL;DR

First part of the “Running React Native everywhere” series: a tutorial about structuring your monorepo to run multiple React Native apps targeting different platforms.

Highlights:

  • Using a monorepo to support multiple platforms with React Native
  • What are Yarn Workspaces nohoist’s benefits
  • Bootstrapping a minimal Yarn Workspaces setup

Multi-platform support

Running React Native on multiple platforms is not a new thing. We’ve been able to run React Native on the web, macOS, and Windows for quite a while now.
The most common and straightforward way to support different platforms with a single React Native codebase is to store all the configuration files required to run the app on all platforms in a single project directory.

For example, if you’re planning to support Android, iOS, Windows, and macOS, by following the React Native for Windows + macOS documentation, you’ll end up with a project that looks like this:

.
└── <project-root>/
    ├── android/
    ├── ios/
    ├── macos/
    ├── src/
    ├── windows/
    ├── app.json
    ├── babel.config.js
    ├── index.js
    ├── metro.config.js
    └── package.json

This structure can work perfectly fine for most use cases.

…but, from my personal experience, it has a few drawbacks that get exponentially worse the more your codebase grows.

First and foremost: you’re forced to use the same version of React Native on every platform you support.
Therefore, you won’t be able to update React Native until all platforms are ready to support it.
Even though this limitation may not seem like an issue at first, it might get you stuck on older versions of React Native if even a single platform is not compatible with the latest versions.
Let’s look at a real case example: as of today (Sep 2021), the latest stable version for React Native for macOS supports only React Native 0.63.4 (released in Oct 2020).
Assuming we’re planning to support both Android/iOS and macOS, we won’t be able to update React Native in our project until React Native for macOS supports it. And we’d be stuck on an (almost) 1-year-old version of React Native even on Android/iOS.
P.S.: To be clear, I’m not criticizing React Native for macOS’s release cycle. It’s just the first example of versions gap that comes to my mind.

Second, sharing code with other projects (e.g., backend code, web apps) may get complicated.
Out-of-the-box, React Native’s metro bundler cannot reference code outside of the project’s root directory. You can configure it to do so (and we’ll do it as well later on). Still, once you do it, you’ll also need to ensure dependencies resolution works correctly (to avoid loading two different versions of the same library, for example); which might not be as easy as it may sound.

Last, because you’re supporting multiple platforms in a single directory, it’s easy to end up with confusing indirections and branches in platform-specific files.
This may be just a “me” thing, but I find it hard to navigate around configuration files of projects that support multiple platforms. At first glance, it may look like all platforms use the same configuration files. But once you dig a bit deeper, you realize that each platform requires some ad-hoc tweaks to the configuration files (for Metro, Babel, Webpack, etc.).
Want an example from a codebase I wrote?
Check out Ordinary Puzzles, which is a mobile, web, and Electron app.
It’s not easy to understand what files are used by which platform (e.g., what platform build phase is using babel.config.js?)

A possible answer to these issues: Yarn Workspaces monorepo

A possible way to solve these issues that I’ve been using with success for a while now (and the one we’ll use in this guide) is structuring the project as a Yarn Workspaces monorepo, keeping platform-specific code in different packages.

Yarn Workspaces (and monorepos in general) is a way to allow multiple apps to coexist in the same repository and cross-reference each other, easing the overhead of repository management and allowing a higher degree of collaboration among teams.
Each app is known as “package”, and has its own package.json file.

Thanks to Yarn Workspaces, we can go from a single app that runs on different platforms to multiple apps that share common JavaScript code:

.
└── <project-root>/
    # JavaScript code of the app (shared between all apps)
    ├── app/
    │   ├── src/
    │   └── package.json
    # macOS app configuration files and native code
    ├── macos/
    │   ├── macos/
    │   ├── app.json
    │   ├── babel.config.js
    │   ├── index.js
    │   ├── metro.config.js
    │   └── package.json
    # Android/iOS app configuration files and native code
    ├── mobile/
    │   ├── android/
    │   ├── ios/
    │   ├── app.json
    │   ├── babel.config.js
    │   ├── index.js
    │   ├── metro.config.js
    │   └── package.json
    # Windows app configuration files and native code
    └── windows/
        ├── windows/
        ├── app.json
        ├── babel.config.js
        ├── index.js
        ├── metro.config.js
        └── package.json

To me, this structure perfectly suits React Native’s “Learn once, write anywhere” headline.
By using a single project structure, it’s easy to forget that we’re not developing a “single” app: we’re developing different apps (Android, iOS, web, etc.) that run the same JavaScript code.
The difference between a monolithic approach and monorepo, is that in the former, all apps are forced to use the same dependency versions. In the latter, you’re free to use different dependency versions on each app.

This “freedom” comes as a double-edged sword.
Suppose you decide to use two different versions of React Native. In that case, the shared JavaScript code must support both versions: you can’t simply run the current version of React Native on a platform and a two year old version of it on another and expect the shared JavaScript code to just work. Even if React Native is steadily becoming more “stable” you still need to take into account breaking changes.

That said, as we’ll see later on, between platform-specific file names (index.ios.js, index.web.js, etc.) and being able to isolate platform-specific JavaScript code in packages, supporting different dependency versions might be easier than you expect.

Yarn’s nohoist

A crucial part of our monorepo setup is Yarn’s nohoist.

To reduce redundancy, most package managers employ some kind of hoisting scheme to extract and flatten all dependent modules, as much as possible, into a centralized location. Yarn Workspaces store the flattened dependencies in a node_modules directory in the project root and makes it accessible to the workspace packages by symlinking the libraries in the packages’ node_module directory.

While it might appear that we can access all modules from the project’s root node_modules, the reality is that build processes sometimes aren’t able to traverse symlinks. This problem is especially prominent in React Native apps, where both the metro bundler and the native code can’t follow symlinks.

A common way to solve this issue in React Native monorepos, is to configure the metro bundler and the native layer to use the project’s root node_modules directory instead of the package’s one. While this approach ensures you gain all the benefits of the hoisting process, it introduces a few complexities:

  • Each time you update React Native (or a library that requires native linking), you must also update (or at least keep in sync) the native code with the root project’s node_modules directory. To me, this process has always seemed error-prone, because you’re dealing with multiple languages and build-tools.
  • Suppose your packages need different versions of React Native (or of a library that requires native linking). In that case, you can’t ensure React Native will be installed in a specific location (unless you give up the hoisting mechanism) — adding even more complexities to the table.

For these reasons, we’ll use a different approach: Yarn’s nohoist.

Yarn’s nohoist is a setting that disables the selected modules from being hoisted to the project root. They are placed in the actual (child) project instead, just like in a standalone, non-workspaces, project.

Of course, this comes with drawbacks. The most obvious one is that nohoist modules could be duplicated in multiple locations, denying the benefit of hoisting mentioned above. Therefore, we’ll keep nohoist scope as small and explicit as possible, targeting only problematic libraries.

Thanks to nohoist, we can avoid making changes to the native code, and we can keep the monorepo configuration in the JavaScript land. This means we can even extract common metro and webpack settings in a workspace package to share them easily across the entire project.

And, even more importantly, different platforms can use different versions of React Native (and native libraries), favoring incremental updates instead of migrating the entire project.

Please notice that I’m focusing on Yarn Workspaces just because it’s the tool I’m most familiar with. You can achieve similar results with pnpm and nx.

Creating our monorepo

Enough with the theory! Let’s start the setup of our monorepo.

First of all, ensure Yarn 1 (classic) is installed.

Then, initialize a new my-app project

# Create the project dir and cd into it.
mkdir my-app && cd my-app

# Initialize git.
git init
npx gitignore node

Add this package.json:

my-app/package.json
{
  "name": "my-app",
  "version": "0.0.1",
  "private": true,
  "workspaces": {
    "packages": ["packages/*"]
  },
  "scripts": {
    "reset": "find . -type dir -name node_modules | xargs rm -rf && rm -rf yarn.lock"
  }
}
  • The workspaces.packages setting tells Yarn that each package (e.g., mobile, macos, etc.) will live in <root>/packages/.
  • The reset script deletes all the node_modules directories in the project (recursively) and the root yarn.lock file. It may come in handy during the initial phase of the setup if we mistakenly install dependencies that should be nohoisted before adding them to nohoist :)

Create an empty packages directory:

mkdir packages

Finally, the most important part: add a nohoist section to your package.json:

my-app/package.json
 {
   "name": "my-app",
   "version": "0.0.1",
   "private": true,
   "workspaces": {
     "packages": ["packages/*"],
+    "nohoist": ["**/react", "**/react-dom"]
   },
   "scripts": {
     "reset": "find . -type dir -name node_modules | xargs rm -rf && rm -rf yarn.lock"
   }
 }

This nohoist section will tell Yarn that the listed dependencies (specified as glob patterns) should be installed in the node_modules directory of each package instead of the root project’s one.
For now, I just added react and react-dom because once we start supporting the React Native for Web, it will be easy to get into a state where the app crashes because different versions of React are loaded on the page.

Spoiler: we’ll have to come back and update this list every time we notice a dependency doesn’t work well when hoisted.

We’re done, for now!

Next steps

In the next step, we’ll add support for Android and iOS to our monorepo and learn how to configure the metro bundler dynamically based on the nohoist list.

@mmazzarolo - Sep 12, 2021