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
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
And its 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/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;
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
React Native CLI requires a pascal case name for the generated app — which is why we're using
MyApp
and then renaming the directory tomobile
.
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"
}
}
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);
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
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.
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/**"
]
}
}
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 thereact-native
library should not be hoisted. -
**/react-native/**
tells Yarn that the allreact-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
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_modules
directories 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
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,
+ },
};
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",
},
},
};
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,
},
};
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"
},
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"
},
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!
- Overview
- Monorepo setup
- Android & iOS (☜ you're here)
- Windows & macOS
- The Web
- Electron & browser extension
Originally published at mmazzarolo.com
Top comments (1)
Hey Hi I really appreciate what you are doing.
Can you please mention the steps to follow what to do if we want to add our custom react native module
(
1 - which is located outside of out root
2 - Which is published as npm package.
) into this workspace.
Thanks in Advance.