Running React Native everywhere: Android & iOS

mmazzarolo

Matteo Mazzarolo

Posted on September 21, 2021

Running React Native everywhere: Android & iOS

TL;DR

Second part of the "Running React Native everywhere" 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And its package.json:

{
  "name": "@my-app/app",
  "version": "0.0.0",
  "private": true,
  "main": "src",
  "peerDependencies": {
    "react": "*",
    "react-native": "*"
  }
}
Enter fullscreen mode Exit fullscreen mode

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/app.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;
Enter fullscreen mode Exit fullscreen mode

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

  • Marking @my-app/app as a dependency
  • Importing App: import App from "@my-app/app";

The native mobile code and configuration

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
Enter fullscreen mode Exit fullscreen mode

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:

 {
-  "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"
   }
 }
Enter fullscreen mode Exit fullscreen mode

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

 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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The command will fail with an error like this:

[!] 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.
Enter fullscreen mode Exit fullscreen mode

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:

 {
   "name": "my-app",
   "version": "0.0.1",
   "private": true,
   "workspaces": {
     "packages": [
       "packages/*"
     ],
     "nohoist": [
       "**/react",
       "**/react-dom",
+      "**/react-native",
+      "**/react-native/**"
     ]
   }
 }
Enter fullscreen mode Exit fullscreen mode

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

  • **/react-native tells Yarn that the react-native library should not be hoisted.
  • **/react-native/** tells Yarn that the all react-native's dependencies (e.g., metro, react-native-cli, etc.) should not be hoisted.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And update the metro config:

 const path = require("path");
 const exclusionList = require("metro-config/src/defaults/exclusionList");
 const { getMetroConfig } = require("react-native-monorepo-tools");

+const yarnWorkspacesMetroConfig = getMetroConfig();

 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: yarnWorkspacesMetroConfig.watchFolders,
+  resolver: {
+    // Ensure we resolve nohoist libraries from this directory.
+    blockList: exclusionList(yarnWorkspacesMetroConfig.blockList),
+    extraNodeModules: yarnWorkspacesMetroConfig.extraNodeModules,
+  },
 };
Enter fullscreen mode Exit fullscreen mode

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

const path = require("path");
const exclusionList = require("metro-config/src/defaults/exclusionList");
const { getMetroConfig } = require("react-native-monorepo-tools");

const yarnWorkspacesMetroConfig = getMetroConfig();

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",
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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:

 const path = require("path");
 const exclusionList = require("metro-config/src/defaults/exclusionList");
 const {
   getMetroConfig,
   getAndroidAssetsResolutionFix,
 } = require("react-native-monorepo-tools");

 const yarnWorkspacesMetroConfig = getMetroConfig();

+const androidAssetsResolutionFix = getMetroAndroidAssetsResolutionFix();

 module.exports = {
   transformer: {
     getTransformOptions: async () => ({
+      // Apply the Android assets resolution fix to the public path...
+      publicPath: androidAssetsResolutionFix.publicPath,
+      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: yarnWorkspacesMetroConfig.watchFolders,
   resolver: {
     // Ensure we resolve nohoist libraries from this directory.
     blockList: exclusionList(yarnWorkspacesMetroConfig.blockList),
     extraNodeModules: yarnWorkspacesMetroConfig.extraNodeModules,
   },
 };
Enter fullscreen mode Exit fullscreen mode

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:

"scripts": {
  "android": "react-native run-android",
  "ios": "react-native run-ios",
  "start": "react-native start",
  "studio": "studio android",
  "xcode": "xed ios"
},
Enter fullscreen mode Exit fullscreen mode

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

"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"
},
Enter fullscreen mode Exit fullscreen mode

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.

Stay tuned!

Originally published at mmazzarolo.com

💖 💪 🙅 🚩
mmazzarolo
Matteo Mazzarolo

Posted on September 21, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related