Mazzarolo MatteoMazzarolo Matteo

React Native monorepo for every platform: Windows & macOS

By Mazzarolo Matteo


TL;DR

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

This time, we'll focus on the Windows and macOS platforms.

About React Native for Windows + macOS

React Native for Windows + macOS brings React Native support for the Windows SDK as well as the macOS 10.13 SDK. With this, you can use JavaScript to build native Windows apps for all devices supported by Windows 10 and higher including PCs, tablets, 2-in-1s, Xbox, Mixed reality devices, etc., as well as the macOS desktop and laptop ecosystems.

The React Native for Windows + macOS development flow is very similar to the Android and iOS one. If you're already familiar with building mobile React Native apps and with the Windows or macOS SDK, you should be able to quickly jump into a React Native for Windows + macOS codebase.

Both the Windows and macOS platforms are currently being maintained by Microsoft.
As of today, React Native for Windows is in a much more stable shape than React Native for macOS, but they're both getting better and better.

The React Native for Windows + macOS documentation follows a classic approach to setup the projects: it shows you how to add them directly in an existing React Native mobile app, resulting in having the Android, iOS, macOS, and Windows code located in the same directory, sharing a single metro bundler setup.
As explained in the monorepo setup guide, we'll follow a slightly different approach and create a workspace for each platform. By doing so, we're making our codebase a bit more complex in exchange for a simplified incremental React Native update path, because we won't be forced to use the same React Native version on all platforms.

Please keep in mind that this solution is probably a bit over-engineered if you do already expect to always use the same React Native version on all platforms. In that case, I'd suggest you to go with the classic approach shown in the React Native for Windows + macOS documentation 👍

To add support for the Windows and macOS platforms to our monorepo, we'll follow the same pattern we used with the mobile app, creating a workspace for each platform:

.
└── <project-root>/
    └── packages/
        # React Native JavaScript code shared across the apps
        ├── app/
           ├── src/
           └── package.json
        # macOS app configuration files and native code
        └── macos/
           ├── macos/
           ├── index.js
           ├── metro.config.js
           └── package.json
        # Android/iOS app configuration files and native code
        └── mobile/
           ├── android/
           ├── ios/
           ├── index.js
           ├── metro.config.js
           └── package.json
        # Windows app configuration files and native code
        └── windows/
            ├── windows/
            ├── index.js
            ├── metro.config.js
            └── package.json
.
└── <project-root>/
    └── packages/
        # React Native JavaScript code shared across the apps
        ├── app/
           ├── src/
           └── package.json
        # macOS app configuration files and native code
        └── macos/
           ├── macos/
           ├── index.js
           ├── metro.config.js
           └── package.json
        # Android/iOS app configuration files and native code
        └── mobile/
           ├── android/
           ├── ios/
           ├── index.js
           ├── metro.config.js
           └── package.json
        # Windows app configuration files and native code
        └── windows/
            ├── windows/
            ├── index.js
            ├── metro.config.js
            └── package.json

Something worth noticing is that React Native for Windows + macOS uses metro bundler, just like React Native mobile does.
So we can leverage the same monorepo tooling we used in our mobile app! 💥

Windows

To create the windows workspace we'll follow the same procedure we used for the mobile one.

First of all, add the react-native-windows library to the nohoist list in the root's 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/**",
+      "**/react-native-windows",
     ]
   }
 }
my-app/package.json
 {
   "name": "my-app",
   "version": "0.0.1",
   "private": true,
   "workspaces": {
     "packages": [
       "packages/*"
     ],
     "nohoist": [
       "**/react",
       "**/react-dom",
       "**/react-native",
       "**/react-native/**",
+      "**/react-native-windows",
     ]
   }
 }

Then, from the packages directory, scaffold a new React Native for Windows project:

npx react-native init MyApp --template react-native@^0.65.0 && mv MyApp windows
npx react-native init MyApp --template react-native@^0.65.0 && mv MyApp windows

Update windows/package.json:

my-app/packages/windows/package.json
 {
-  "name": "MyApp",
+  "name": "@my-app/windows",
   "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"
   }
my-app/packages/windows/package.json
 {
-  "name": "MyApp",
+  "name": "@my-app/windows",
   "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"
   }

Update windows/index.js to point to our app workspace:

my-app/packages/windows/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/windows/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);

And finalize the Windows project setup:

Last but not least, use react-native-monorepo-tools to make metro compatible with Yarn Workspaces:

my-app/packages/windows/metro.config.js
 const path = require("path");
 const exclusionList = require("metro-config/src/defaults/exclusionList");
 const { getMetroTools } = require("react-native-monorepo-tools");
 
+// Get the metro settings to make it compatible with Yarn workspaces.
+const monorepoMetroTools = getMetroTools({
+  reactNativeAlias: "react-native-windows",
+});
 
 module.exports = {
   resolver: {
     blockList: exclusionList([
       // This stops "react-native run-windows" from causing the metro server to crash if its already running
       new RegExp(
         `${path.resolve(__dirname, "windows").replace(/[/\\]/g, "/")}.*`
       ),
       // This prevents "react-native run-windows" from hitting: EBUSY: resource busy or locked, open msbuild.ProjectImports.zip
       /.*\.ProjectImports\.zip/,
 
+      // Ensure we resolve nohoist libraries from this directory.
+      ...monorepoMetroTools.blockList,
     ]),
+    // Ensure we resolve nohoist libraries from this directory.
+    extraNodeModules: monorepoMetroTools.extraNodeModules,
   },
+  // Add additional Yarn workspace package roots to the module map.
+  // This allows importing from any workspace.
+  watchFolders: monorepoMetroTools.watchFolders,
   transformer: {
     getTransformOptions: async () => ({
       transform: {
         experimentalImportSupport: false,
         inlineRequires: true,
       },
     }),
   },
 };
my-app/packages/windows/metro.config.js
 const path = require("path");
 const exclusionList = require("metro-config/src/defaults/exclusionList");
 const { getMetroTools } = require("react-native-monorepo-tools");
 
+// Get the metro settings to make it compatible with Yarn workspaces.
+const monorepoMetroTools = getMetroTools({
+  reactNativeAlias: "react-native-windows",
+});
 
 module.exports = {
   resolver: {
     blockList: exclusionList([
       // This stops "react-native run-windows" from causing the metro server to crash if its already running
       new RegExp(
         `${path.resolve(__dirname, "windows").replace(/[/\\]/g, "/")}.*`
       ),
       // This prevents "react-native run-windows" from hitting: EBUSY: resource busy or locked, open msbuild.ProjectImports.zip
       /.*\.ProjectImports\.zip/,
 
+      // Ensure we resolve nohoist libraries from this directory.
+      ...monorepoMetroTools.blockList,
     ]),
+    // Ensure we resolve nohoist libraries from this directory.
+    extraNodeModules: monorepoMetroTools.extraNodeModules,
   },
+  // Add additional Yarn workspace package roots to the module map.
+  // This allows importing from any workspace.
+  watchFolders: monorepoMetroTools.watchFolders,
   transformer: {
     getTransformOptions: async () => ({
       transform: {
         experimentalImportSupport: false,
         inlineRequires: true,
       },
     }),
   },
 };

That should be it! We can now run yarn windows from the windows workspace to run the app.

macOS

Like for Windows setup, to create the macos workspace we'll follow the same procedure we used for the mobile one.

The main difference here is that, as of today, the latest stable version available for React Native for macOS is 0.63.
So we need to take into account that our app will run on two different React Native versions: 0.65 for Android, iOS, and Windows, and 0.63 for macOS.

Let's start by adding the react-native-macos library to the nohoist list in the root's 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/**",
+     "**/react-native-macos",
      "**/react-native-windows"
    ]
  }
}
my-app/package.json
{
  "name": "my-app",
  "version": "0.0.1",
  "private": true,
  "workspaces": {
    "packages": [
      "packages/*"
    ],
    "nohoist": [
      "**/react",
      "**/react-dom",
      "**/react-native",
      "**/react-native/**",
+     "**/react-native-macos",
      "**/react-native-windows"
    ]
  }
}

Then, from the packages directory, scaffold a new React Native for macOS project:

npx react-native init MyApp --template react-native@^0.65.0 && mv MyApp macos
npx react-native init MyApp --template react-native@^0.65.0 && mv MyApp macos

Update macos/package.json:

my-app/packages/macos/package.json
 {
-  "name": "MyApp",
+  "name": "@my-app/macos",
   "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": "16.13.1",
     "react-native": "0.63.0"
   }
my-app/packages/macos/package.json
 {
-  "name": "MyApp",
+  "name": "@my-app/macos",
   "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": "16.13.1",
     "react-native": "0.63.0"
   }

Update macos/index.js to point to our app workspace:

my-app/packages/macos/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/macos/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);

And finalize the macOS project setup:

Last but not least, use react-native-monorepo-tools to make metro compatible with Yarn Workspaces:

my-app/packages/macos/metro.config.js
 const exclusionList = require("metro-config/src/defaults/exclusionList");
 const { getMetroTools } = require("react-native-monorepo-tools");
 
+// Get the metro settings to make it compatible with Yarn workspaces.
+const monorepoMetroTools = getMetroTools({
+  reactNativeAlias: "react-native-macos",
+});
 
 module.exports = {
   transformer: {
     getTransformOptions: async () => ({
       transform: {
         experimentalImportSupport: false,
         inlineRequires: true,
       },
     }),
   },
+  // Add additional Yarn workspace package roots to the module map.
+  // This allows importing from any workspace.
+  watchFolders: monorepoMetroTools.watchFolders,
+  resolver: {
+    // Ensure we resolve nohoist libraries from this directory.
+    blacklistRE: exclusionList(monorepoMetroTools.blockList),
+    extraNodeModules: monorepoMetroTools.extraNodeModules,
+  },
 };
my-app/packages/macos/metro.config.js
 const exclusionList = require("metro-config/src/defaults/exclusionList");
 const { getMetroTools } = require("react-native-monorepo-tools");
 
+// Get the metro settings to make it compatible with Yarn workspaces.
+const monorepoMetroTools = getMetroTools({
+  reactNativeAlias: "react-native-macos",
+});
 
 module.exports = {
   transformer: {
     getTransformOptions: async () => ({
       transform: {
         experimentalImportSupport: false,
         inlineRequires: true,
       },
     }),
   },
+  // Add additional Yarn workspace package roots to the module map.
+  // This allows importing from any workspace.
+  watchFolders: monorepoMetroTools.watchFolders,
+  resolver: {
+    // Ensure we resolve nohoist libraries from this directory.
+    blacklistRE: exclusionList(monorepoMetroTools.blockList),
+    extraNodeModules: monorepoMetroTools.extraNodeModules,
+  },
 };

Run yarn macos (from the macos workspace) et voilà, our React Native app is now running on macOS!

On supporting different React Native versions

Generally, supporting different React Native versions might sound complicated.
From my experience, though, it will rarely be a problem. We only have to worry about breaking changes of React Native JavaScript API/components, which aren't that common nowadays.
And, even if it happens, let's keep in mind that we can always encapsulate platform-specific code in multiple ways.

Root-level scripts

Just like we did for the mobile package, 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).

Add the following scripts to the Windows workspace:

my-app/packages/windows/package.json
"scripts": {
  "start": "react-native start",
  "windows": "react-native run-windows"
},
my-app/packages/windows/package.json
"scripts": {
  "start": "react-native start",
  "windows": "react-native run-windows"
},

And the following scripts to the macOS workspace:

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

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

my-app/package.json
"scripts": {
  "macos:metro": "yarn workspace @my-app/macos start",
  "macos:start": "yarn workspace @my-app/macos macos",
  "macos:xcode": "yarn workspace @my-app/macos xcode",
  "windows:start": "yarn workspace @my-app/windows windows",
  "windows:metro": "yarn workspace @my-app/windows start"
},
my-app/package.json
"scripts": {
  "macos:metro": "yarn workspace @my-app/macos start",
  "macos:start": "yarn workspace @my-app/macos macos",
  "macos:xcode": "yarn workspace @my-app/macos xcode",
  "windows:start": "yarn workspace @my-app/windows windows",
  "windows:metro": "yarn workspace @my-app/windows start"
},

Compatibility and platform-specific code

React Native for Windows + macOS provides compatibility with the vast majority of React Native’s JavaScript API. Features deprecated in React Native should be considered unsupported in React Native for Windows + macOS.
See "API Parity" for details.

Also, React Native provides two ways to organize your Windows-specific and macOS-specific code and separate it from the other platforms:

Next steps

In the next step, we'll add support for the web to our monorepo.