Mazzarolo MatteoMazzarolo Matteo

React Native monorepo for every platform: Android & iOS

By Mazzarolo Matteo


TL;DR

Second part of the "React Native monorepo for every platform" series: a tutorial about structuring your project to run multiple React Native apps targeting different platforms.

This time, we'll build a modular React Native app using a Yarn Workspaces monorepo, starting from Android & iOS.

The next step

Now that the monorepo foundation is in place, we can start building our app.
The next step is encapsulating the shared React Native code and the native Android & iOS code in two different workspaces:

.
└── <project-root>/
    └── packages/
        # React Native JavaScript code shared across the apps
        ├── app/
           ├── src/
           └── 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
.
└── <project-root>/
    └── packages/
        # React Native JavaScript code shared across the apps
        ├── app/
           ├── src/
           └── 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

The shared React Native JavaScript code: packages/app

Lets' start from the shared React Native JavaScript code.
The idea here is to isolate the JavaScript code that runs the app in an app workspace.
We should think about this workspaces as a standard npm library that can work in isolation.
So it will have its own package.json where we'll explicitly declare its dependencies.

Let's start by creating the new package directory:

mkdir packages/app && cd packages/app
mkdir packages/app && cd packages/app

And its package.json:

my-app/packages/app/package.json
{
  "name": "@my-app/app",
  "version": "0.0.0",
  "private": true,
  "main": "src",
  "peerDependencies": {
    "react": "*",
    "react-native": "*"
  }
}
my-app/packages/app/package.json
{
  "name": "@my-app/app",
  "version": "0.0.0",
  "private": true,
  "main": "src",
  "peerDependencies": {
    "react": "*",
    "react-native": "*"
  }
}

As we already explained in the monorepo setup, we're setting react and react-native as peerDependencies because we expect each app that depends on our package to provide their versions of these libraries.

Then, let's create a tiny app in src/index.js:

my-app/packages/app/src/index.js
import React from "react";
import { Image, Platform, SafeAreaView, StyleSheet, Text, View } from "react-native";
import LogoSrc from "./logo.png";
 
export function App() {
  return (
    <SafeAreaView style={styles.root}>
      <Image style={styles.logo} source={LogoSrc} />
      <Text style={styles.text}>Hello from React Native!</Text>
      <View style={styles.platformRow}>
        <Text style={styles.text}>Platform: </Text>
        <View style={styles.platformBackground}>
          <Text style={styles.platformValue}>{Platform.OS}</Text>
        </View>
      </View>
    </SafeAreaView>
  );
}
 
const styles = StyleSheet.create({
  root: {
    height: "100%",
    alignItems: "center",
    justifyContent: "center",
    backgroundColor: "white",
  },
  logo: {
    width: 120,
    height: 120,
    marginBottom: 20,
  },
  text: {
    fontSize: 28,
    fontWeight: "600",
  },
  platformRow: {
    marginTop: 12,
    flexDirection: "row",
    alignItems: "center",
  },
  platformValue: {
    fontSize: 28,
    fontWeight: "500",
  },
  platformBackground: {
    backgroundColor: "#ececec",
    borderWidth: StyleSheet.hairlineWidth,
    borderColor: "#d4d4d4",
    paddingHorizontal: 6,
    borderRadius: 6,
    alignItems: "center",
  },
});
 
export default App;
my-app/packages/app/src/index.js
import React from "react";
import { Image, Platform, SafeAreaView, StyleSheet, Text, View } from "react-native";
import LogoSrc from "./logo.png";
 
export function App() {
  return (
    <SafeAreaView style={styles.root}>
      <Image style={styles.logo} source={LogoSrc} />
      <Text style={styles.text}>Hello from React Native!</Text>
      <View style={styles.platformRow}>
        <Text style={styles.text}>Platform: </Text>
        <View style={styles.platformBackground}>
          <Text style={styles.platformValue}>{Platform.OS}</Text>
        </View>
      </View>
    </SafeAreaView>
  );
}
 
const styles = StyleSheet.create({
  root: {
    height: "100%",
    alignItems: "center",
    justifyContent: "center",
    backgroundColor: "white",
  },
  logo: {
    width: 120,
    height: 120,
    marginBottom: 20,
  },
  text: {
    fontSize: 28,
    fontWeight: "600",
  },
  platformRow: {
    marginTop: 12,
    flexDirection: "row",
    alignItems: "center",
  },
  platformValue: {
    fontSize: 28,
    fontWeight: "500",
  },
  platformBackground: {
    backgroundColor: "#ececec",
    borderWidth: StyleSheet.hairlineWidth,
    borderColor: "#d4d4d4",
    paddingHorizontal: 6,
    borderRadius: 6,
    alignItems: "center",
  },
});
 
export default App;

And save any image in my-app/packages/app/src/logo.png (it will be displayed in the app).

Thanks to Yarn Workspaces, we can now use @my-app/app in any other worskpace by:

The native mobile code and configuration: packages/mobile

Now that the shared React Native code is ready let's create packages/mobile. This workspace will store the Android & iOS native code and import & run `packages/app.

Using React Native CLI, bootstrap a new React Native app within the packages directory.

cd packages && npx react-native init MyApp && mv MyApp mobile
cd packages && npx react-native init MyApp && mv MyApp mobile

React Native CLI requires a pascal case name for the generated app — which is why we're using MyApp and then renaming the directory to mobile.

Then, update the generated package.json by setting the new package name and adding the @my-app/app dependency:

my-app/packages/mobile/package.json
 {
-  "name": "MyApp",
+  "name": "@my-app/mobile",
   "version": "0.0.1",
   "private": true,
   "scripts": {
     "android": "react-native run-android",
     "ios": "react-native run-ios",
     "start": "react-native start",
     "test": "jest",
     "lint": "eslint ."
   },
   "dependencies": {
+    "@my-app/app": "*",
     "react": "17.0.2",
     "react-native": "0.65.1"
   },
   "devDependencies": {
     "@babel/core": "^7.12.9",
     "@babel/runtime": "^7.12.5",
     "babel-jest": "^26.6.3",
     "eslint": "7.14.0",
     "get-yarn-workspaces": "^1.0.2",
     "jest": "^26.6.3",
     "metro-react-native-babel-preset": "^0.66.0",
     "react-native-codegen": "^0.0.7",
     "react-test-renderer": "17.0.2"
   },
   "jest": {
     "preset": "react-native"
   }
 }
my-app/packages/mobile/package.json
 {
-  "name": "MyApp",
+  "name": "@my-app/mobile",
   "version": "0.0.1",
   "private": true,
   "scripts": {
     "android": "react-native run-android",
     "ios": "react-native run-ios",
     "start": "react-native start",
     "test": "jest",
     "lint": "eslint ."
   },
   "dependencies": {
+    "@my-app/app": "*",
     "react": "17.0.2",
     "react-native": "0.65.1"
   },
   "devDependencies": {
     "@babel/core": "^7.12.9",
     "@babel/runtime": "^7.12.5",
     "babel-jest": "^26.6.3",
     "eslint": "7.14.0",
     "get-yarn-workspaces": "^1.0.2",
     "jest": "^26.6.3",
     "metro-react-native-babel-preset": "^0.66.0",
     "react-native-codegen": "^0.0.7",
     "react-test-renderer": "17.0.2"
   },
   "jest": {
     "preset": "react-native"
   }
 }

Finally, update packages/mobile/index.js to use @my-app/app instead of the app template shipped with React Native:

my-app/packages/mobile/index.js
 import { AppRegistry } from "react-native";
-import App from "./App";
+import App from "@my-app/app";
 import { name as appName } from "./app.json";
 
 AppRegistry.registerComponent(appName, () => App);
my-app/packages/mobile/index.js
 import { AppRegistry } from "react-native";
-import App from "./App";
+import App from "@my-app/app";
 import { name as appName } from "./app.json";
 
 AppRegistry.registerComponent(appName, () => App);

Updating the nohoist list

We should be ready to run the app now, right?
Well... kinda. We still need to update the nohoist section of the root package.json to include all the libraries required by React Native.

To understand why we need to do so, try installing the iOS pods:

cd packages/mobile/ios && pod install
cd packages/mobile/ios && pod install

The command will fail with an error like this:

» cd packages/mobile/ios && pod install
 
[!] Invalid `Podfile` file: cannot load such file -- /Users/me/workspace/react-native-universal-monorepo-js/packages/mobile/node_modules/@react-native-community/cli-platform-ios/native_modules.
 
 #  from /Users/me/workspace/react-native-universal-monorepo-js/packages/mobile/ios/Podfile:2
 #  -------------------------------------------
 #  require_relative '../node_modules/react-native/scripts/react_native_pods'
 >  require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
 #
 #  -------------------------------------------
» cd packages/mobile/ios && pod install
 
[!] Invalid `Podfile` file: cannot load such file -- /Users/me/workspace/react-native-universal-monorepo-js/packages/mobile/node_modules/@react-native-community/cli-platform-ios/native_modules.
 
 #  from /Users/me/workspace/react-native-universal-monorepo-js/packages/mobile/ios/Podfile:2
 #  -------------------------------------------
 #  require_relative '../node_modules/react-native/scripts/react_native_pods'
 >  require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
 #
 #  -------------------------------------------

As we explained in the previous post, by default Yarn Workspaces will install the dependencies of each package (app, mobile, etc.) in <project-root>/node_modules (AKA "hoisting").
This behaviour doesn't work well with React Native, because the native code located in mobile/ios and mobile/android in some cases references libraries from mobile/node_modules instead of <project-root>/node_modules.
Luckily, we can opt-out of Yarn workspaces' hoisting for specific libraries by adding them to the nohoist setting in the root package.json:

my-app/package.json
 {
   "name": "my-app",
   "version": "0.0.1",
   "private": true,
   "workspaces": {
     "packages": [
       "packages/*"
     ],
     "nohoist": [
       "**/react",
       "**/react-dom",
+      "**/react-native",
+      "**/react-native/**"
     ]
   }
 }
my-app/package.json
 {
   "name": "my-app",
   "version": "0.0.1",
   "private": true,
   "workspaces": {
     "packages": [
       "packages/*"
     ],
     "nohoist": [
       "**/react",
       "**/react-dom",
+      "**/react-native",
+      "**/react-native/**"
     ]
   }
 }

Adding the libraries from the diff above should be enough to make an app bootstrapped with React Native 0.65 work correctly:

You can completely opt-out from hoisting on all libraries (e.g., with "nohoist": ["**/**"]), but I wouldn't advise doing so unless you feel like maintaining the list of hoisted dependencies becomes a burden.

Once you've updated the nohoist list, run yarn reset && yarn from the project root to re-install the dependencies using the updated settings.

Now cd packages/mobile/ios && pod install should install pods correctly.

Making metro bundler compatible with Yarn workspaces

Before we can run the app, we still need do one more thing: make metro bundler compatible with Yarn workspaces' hoisting.

Metro bundler is the JavaScript bundler currently used by React Native.
One of metro's most famous limitations (and issue number #1 in its GitHub repository) is its inability to follow symlinks.
Therefore, since all hoisted libraries (basically all libraries not specified in the nohoist list) are installed in mobile/node_modules as symlinks from <root>/node_modules, metro won't be able to detect them.
Additionally, because of this issue, metro won't even be able to resolve other workspaces (e.g., @my-app/app) since they're outside of the mobile directory.

For example, running the app on iOS will now show the following (or a similar) error:

error: Error: Unable to resolve module @babel/runtime/helpers/interopRequireDefault from /Users/me/workspace/react-native-universal-monorepo-js/packages/mobile/index.js: @babel/runtime/helpers/interopRequireDefault could not be found within the project or in these directories:
  node_modules

In this specific case, metro is telling us that he's unable to find the @babel/runtime library in mobile/node_modules. And rightfully so: @babel/runtime is not part of our nohoist list, so it will probably be installed in <root>/node_modules instead of mobile/node_modules.

Luckily, we have several metro configuration options at our disposal to fix this problem.

With the help of a couple of tools, we can update the metro configuration file (mobile/metro.config.js) to make metro aware of node_modulesdirectories available outside of the mobile directory (so that it can resolve @my-app/app)... with the caveat that libraries from the nohoist list should always be resolved from mobile/node_modules.

To do so, install react-native-monorepo-tools, a set of utilities for making metro compatible with Yarn workspaces based on our nohoist list.

yarn add -D react-native-monorepo-tools
yarn add -D react-native-monorepo-tools

And update the metro config:

my-app/packages/mobile/metro.config.js
 const exclusionList = require("metro-config/src/defaults/exclusionList");
 const { getMetroTools } = require("react-native-monorepo-tools");
 
+const monorepoMetroTools = getMetroTools();
 
 module.exports = {
   transformer: {
     getTransformOptions: async () => ({
       transform: {
         experimentalImportSupport: false,
         inlineRequires: false,
       },
     }),
   },
+  // Add additional Yarn workspace package roots to the module map.
+  // This allows importing importing from all the project's packages.
+  watchFolders: monorepoMetroTools.watchFolders,
+  resolver: {
+    // Ensure we resolve nohoist libraries from this directory.
+    blockList: exclusionList(monorepoMetroTools.blockList),
+    extraNodeModules: monorepoMetroTools.extraNodeModules,
+  },
 };
my-app/packages/mobile/metro.config.js
 const exclusionList = require("metro-config/src/defaults/exclusionList");
 const { getMetroTools } = require("react-native-monorepo-tools");
 
+const monorepoMetroTools = getMetroTools();
 
 module.exports = {
   transformer: {
     getTransformOptions: async () => ({
       transform: {
         experimentalImportSupport: false,
         inlineRequires: false,
       },
     }),
   },
+  // Add additional Yarn workspace package roots to the module map.
+  // This allows importing importing from all the project's packages.
+  watchFolders: monorepoMetroTools.watchFolders,
+  resolver: {
+    // Ensure we resolve nohoist libraries from this directory.
+    blockList: exclusionList(monorepoMetroTools.blockList),
+    extraNodeModules: monorepoMetroTools.extraNodeModules,
+  },
 };

Here's how the new settings look like under-the-hood:

const path = require("path");
const exclusionList = require("metro-config/src/defaults/exclusionList");
const { getMetroTools } = require("react-native-monorepo-tools");
 
const monorepoMetroTools = getMetroTools();
 
module.exports = {
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: false,
      },
    }),
  },
  // Add additional Yarn workspaces to the module map.
  // This allows importing importing from all the project's packages.
  watchFolders: {
    '/Users/me/my-app/node_modules',
    '/Users/me/my-app/packages/app/',
    '/Users/me/my-app/packages/build-tools/',
    '/Users/me/my-app/packages/mobile/'
  },
  resolver: {
    // Ensure we resolve nohoist libraries from this directory.
    // With "((?!mobile).)", we're blocking all the cases were metro tries to
    // resolve nohoisted libraries from a directory that is not "mobile".
    blockList: exclusionList([
      /^((?!mobile).)*\/node_modules\/@react-native-community\/cli-platform-ios\/.*$/,
      /^((?!mobile).)*\/node_modules\/@react-native-community\/cli-platform-android\/.*$/,
      /^((?!mobile).)*\/node_modules\/hermes-engine\/.*$/,
      /^((?!mobile).)*\/node_modules\/jsc-android\/.*$/,
      /^((?!mobile).)*\/node_modules\/react\/.*$/,
      /^((?!mobile).)*\/node_modules\/react-native\/.*$/,
      /^((?!mobile).)*\/node_modules\/react-native-codegen\/.*$/,
    ]),
    extraNodeModules: {
      "@react-native-community/cli-platform-ios":
        "/Users/me/my-app/packages/mobile/node_modules/@react-native-community/cli-platform-ios",
      "@react-native-community/cli-platform-android":
        "/Users/me/my-app/packages/mobile/node_modules/@react-native-community/cli-platform-android",
      "hermes-engine":
        "/Users/me/my-app/packages/mobile/node_modules/hermes-engine",
      "jsc-android":
        "/Users/me/my-app/packages/mobile/node_modules/jsc-android",
      react: "/Users/me/my-app/packages/mobile/node_modules/react",
      "react-native":
        "/Users/me/my-app/packages/mobile/node_modules/react-native",
      "react-native-codegen":
        "/Users/me/my-app/packages/mobile/node_modules/react-native-codegen",
    },
  },
};
const path = require("path");
const exclusionList = require("metro-config/src/defaults/exclusionList");
const { getMetroTools } = require("react-native-monorepo-tools");
 
const monorepoMetroTools = getMetroTools();
 
module.exports = {
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: false,
      },
    }),
  },
  // Add additional Yarn workspaces to the module map.
  // This allows importing importing from all the project's packages.
  watchFolders: {
    '/Users/me/my-app/node_modules',
    '/Users/me/my-app/packages/app/',
    '/Users/me/my-app/packages/build-tools/',
    '/Users/me/my-app/packages/mobile/'
  },
  resolver: {
    // Ensure we resolve nohoist libraries from this directory.
    // With "((?!mobile).)", we're blocking all the cases were metro tries to
    // resolve nohoisted libraries from a directory that is not "mobile".
    blockList: exclusionList([
      /^((?!mobile).)*\/node_modules\/@react-native-community\/cli-platform-ios\/.*$/,
      /^((?!mobile).)*\/node_modules\/@react-native-community\/cli-platform-android\/.*$/,
      /^((?!mobile).)*\/node_modules\/hermes-engine\/.*$/,
      /^((?!mobile).)*\/node_modules\/jsc-android\/.*$/,
      /^((?!mobile).)*\/node_modules\/react\/.*$/,
      /^((?!mobile).)*\/node_modules\/react-native\/.*$/,
      /^((?!mobile).)*\/node_modules\/react-native-codegen\/.*$/,
    ]),
    extraNodeModules: {
      "@react-native-community/cli-platform-ios":
        "/Users/me/my-app/packages/mobile/node_modules/@react-native-community/cli-platform-ios",
      "@react-native-community/cli-platform-android":
        "/Users/me/my-app/packages/mobile/node_modules/@react-native-community/cli-platform-android",
      "hermes-engine":
        "/Users/me/my-app/packages/mobile/node_modules/hermes-engine",
      "jsc-android":
        "/Users/me/my-app/packages/mobile/node_modules/jsc-android",
      react: "/Users/me/my-app/packages/mobile/node_modules/react",
      "react-native":
        "/Users/me/my-app/packages/mobile/node_modules/react-native",
      "react-native-codegen":
        "/Users/me/my-app/packages/mobile/node_modules/react-native-codegen",
    },
  },
};

You should finally be able to to run your app on iOS now:

Fixing the Android assets resolution bug

If you run your app on Android, you'll notice that images won't be loaded correctly:

This is because of an open issue with the metro bundler logic used to load assets outside of the root directory on android (like our app/src/logo.png image).

To fix this issue, we can patch the metro bundler assets resolution mechanism by adding a custom server middleware in the metro config.
The way the fix works is quite weird, but since it's available in react-native-monorepo-tools you shouldn't have to worry too much about it.

You can add it to metro the metro config this way:

my-app/packages/mobile/metro.config.js
 const exclusionList = require("metro-config/src/defaults/exclusionList");
 const {
   getMetroTools,
   getAndroidAssetsResolutionFix,
 } = require("react-native-monorepo-tools");
 
 const monorepoMetroTools = getMetroTools();
 
+const androidAssetsResolutionFix = getMetroAndroidAssetsResolutionFix();
 
 module.exports = {
   transformer: {
+    publicPath: androidAssetsResolutionFix.publicPath,
     getTransformOptions: async () => ({
       // Apply the Android assets resolution fix to the public path...
       transform: {
         experimentalImportSupport: false,
         inlineRequires: false,
       },
     }),
   },
+  server: {
+    // ...and to the server middleware.
+    enhanceMiddleware: (middleware) => {
+      return androidAssetsResolutionFix.applyMiddleware(middleware);
+    },
+  },
   // Add additional Yarn workspace package roots to the module map.
   // This allows importing importing from all the project's packages.
   watchFolders: monorepoMetroTools.watchFolders,
   resolver: {
     // Ensure we resolve nohoist libraries from this directory.
     blockList: exclusionList(monorepoMetroTools.blockList),
     extraNodeModules: monorepoMetroTools.extraNodeModules,
   },
 };
my-app/packages/mobile/metro.config.js
 const exclusionList = require("metro-config/src/defaults/exclusionList");
 const {
   getMetroTools,
   getAndroidAssetsResolutionFix,
 } = require("react-native-monorepo-tools");
 
 const monorepoMetroTools = getMetroTools();
 
+const androidAssetsResolutionFix = getMetroAndroidAssetsResolutionFix();
 
 module.exports = {
   transformer: {
+    publicPath: androidAssetsResolutionFix.publicPath,
     getTransformOptions: async () => ({
       // Apply the Android assets resolution fix to the public path...
       transform: {
         experimentalImportSupport: false,
         inlineRequires: false,
       },
     }),
   },
+  server: {
+    // ...and to the server middleware.
+    enhanceMiddleware: (middleware) => {
+      return androidAssetsResolutionFix.applyMiddleware(middleware);
+    },
+  },
   // Add additional Yarn workspace package roots to the module map.
   // This allows importing importing from all the project's packages.
   watchFolders: monorepoMetroTools.watchFolders,
   resolver: {
     // Ensure we resolve nohoist libraries from this directory.
     blockList: exclusionList(monorepoMetroTools.blockList),
     extraNodeModules: monorepoMetroTools.extraNodeModules,
   },
 };

Try running Android — it should work correctly now 👍

Developing and updating the app

By using react-native-monorepo-tools in the metro bundler configuration, we are consolidating all our Yarn workspaces settings into the root package.json's nohoist list.
Whenever we need to add a new library that doesn't work well when hoisted (e.g., a native library), we can add it to the nohoist list and run yarn again so that the metro config can automatically pick up the updated settings.

Additionally, since we haven't touched the native code, updating to newer versions of React Native shouldn't be an issue (as long as there aren't breaking changes in metro bundler).

Root-level scripts

To improve a bit the developer experience, I recommend adding a few scripts to the top-level package.json to invoke workspace-specific scripts (to avoid having to cd into a directory every time you need to run a script).

For example, you can add the following scripts to the mobile workspace:

my-app/packages/mobile/package.json
"scripts": {
  "android": "react-native run-android",
  "ios": "react-native run-ios",
  "start": "react-native start",
  "studio": "studio android",
  "xcode": "xed ios"
},
my-app/packages/mobile/package.json
"scripts": {
  "android": "react-native run-android",
  "ios": "react-native run-ios",
  "start": "react-native start",
  "studio": "studio android",
  "xcode": "xed ios"
},

And then you can reference them from the root this way:

my-app/package.json
"scripts": {
  "android:metro": "yarn workspace @my-app/mobile start",
  "android:start": "yarn workspace @my-app/mobile android",
  "android:studio": "yarn workspace @my-app/mobile studio",
  "ios:metro": "yarn workspace @my-app/mobile start",
  "ios:start": "yarn workspace @my-app/mobile ios",
  "ios:xcode": "yarn workspace @my-app/mobile xcode"
},
my-app/package.json
"scripts": {
  "android:metro": "yarn workspace @my-app/mobile start",
  "android:start": "yarn workspace @my-app/mobile android",
  "android:studio": "yarn workspace @my-app/mobile studio",
  "ios:metro": "yarn workspace @my-app/mobile start",
  "ios:start": "yarn workspace @my-app/mobile ios",
  "ios:xcode": "yarn workspace @my-app/mobile xcode"
},

This pattern allows us to run workspace-specific script directly from the root directory.

Next steps

In the next step, we'll add support for Windows and macOS to our monorepo.