ReactJS, React Native

Code sharing in React Native TypeScript projects

React Native setup in monorepo that works, at last

The more straightforward way to integrate React Native into a monorepo.


Preface

React Native is a great library as it enables code-sharing across different platforms. But since we are here for this specific topic on setting up a React Native project monorepo, I figure you do not need to hear some added-for-the-SEO introductions found on most other "React Native Tutorial" results.

I read through many other posts before settling for my solutions. Some of them are outdated for using previous React Native versions, while others spill out some jumble "as we can see it is easy to blah blah" that did not even work at all (as if they are just filler content).

The most promising one I came across is Tutorial: How to share code between iOS, Android & Web using React Native, react-native-web and monorepo. However, this approach is difficult to configure because it requires editing files inside ios and android directories. I do not prefer that because whatever is in there, if messed up, well good luck getting it to work ever again. Also, it is easier to upgrade in the future if we leave them alone.

Requirement

I get this to work on the specific version of React Native that is 0.64.x, so I cannot guarantee that this works with older versions.

This also assumes the project is set up with the following structure:

.
├── app
├── web
├── shared-a
├── shared-b

In the above structure, let's say both app (React Native) and web depend on shared-a and shared-b while shared-b depends on shared-a. If you follow the structure of using the packages folder, some minor modifications will do the job.

I also use yarn 1 for its nohoist feature. I initially tried with npm 7 workspaces, but its lack of the nohoist was a blocker.

Setting up shared packages

package.json

In each shared packages, set up package.json (yarn init) to mirror the following:

{
"name": "shared-a",
"version": "0.0.0",
"scripts": {
"build": "tsc && babel src -d dist --extensions \".ts\""
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-gesture-handler": "*",
"react-native-reanimated": "*",
"react-native-svg": "*"
}
}

As we notice, I specify all shared dependencies in peerDependencies as to not install them. That is to avoid cases where two different versions of modules like react-native-reanimated are required, which causes undefined errors like Animated node with ID 2 already exists.

Now, this approach comes with an issue and that is TypeScript cannot find the packages' definitions, which I will resolve in a later section.

For shared-b, I would also add shared-a into dependencies:

{
"name": "shared-b",
"version": "0.0.0",
"scripts": {
"build": "tsc && babel src -d dist --extensions \".ts\""
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"dependencies": {
"shared-a": "0.0.0"
}
}

tsconfig.json

Below is my configuration for the shared package shared-a:

// shared-a/tsconfig.json
{
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"module": "ESNext",
"target": "ESNext",
"jsx": "react-jsx",
"declaration": true,
"emitDeclarationOnly": true,
"composite": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

The key setting is composite, which allows other packages to refer to as I soon explain in the following tsconfig for shared-b:

// shared-b/tsconfig.json
{
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"module": "ESNext",
"target": "ESNext",
"jsx": "react-jsx",
"declaration": true,
"emitDeclarationOnly": true,
"composite": true
},
"references": [
{
"path": "../shared-a"
}
],
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

To use references, the referred package must enable composite. Now if we try to CTRL + click an import of shared-a in shared-b source, our code editor will link to the correct file.

Build the packages

To consume the shared packages in our app, I have to transpile it from TypeScript into JavaScript.

The way I choose to do it is with Babel. Some may suggest using tsc, but tsc compilation often results in some odd behaviors like microsoft/TypeScript/#13965 so I used the former.

The following is my babel.config.json with the use of @babel/preset-typescript:

{
"presets": ["@babel/preset-typescript"]
}

Since Babel does not emit declarations, I also ran tsc with declarationOnly as specified in the above tsconfig.json.

{
"scripts": {
"build": "tsc && babel src -d dist --extensions \".ts\""
}
}

Since we also want the packages to rebuild themselves on changes, we can use tsc --watch, babel -w, along with the package concurrently.

{
"scripts": {
"build:dts": "tsc",
"build:babel": "babel src -d dist --extensions \".ts\"",
"build": "yarn build:dts && yarn build:babel",
"build:watch": "concurrently \"yarn build:dts --watch\" \"yarn build:babel -w\""
}
}

A tip is to add a script to the root package.json to trigger the above before starting developing the React Native app:

{
"scripts": {
"build:watch": "concurrently \"yarn workspace shared-a run build:watch\" \"yarn workspace shared-b run build:watch\""
}
}

Setting up the app

package.json

This approach does not require editing anything inside android or ios directories or doing find-and-replace of ../node_modules/react-native like some other tutorials.

To avoid having to do that, we have to edit the root package.json.

{
"workspaces": {
"packages": ["app", "shared-a", "shared-b", "web"],
"nohoist": [
"**/react",
"**/react-native",
"**/react-native-*",
"**/react-native/**",
"**/@react-native-*/*"
]
}
}

Using nohoist allows react-native to stay where it is and avoid further issues (like not having to edit its references inside android and ios folders).

We may also notice that I disable hoisting for some React Native packages using the pattern react-native-*. This was due to an issue where the native modules, such as @react-native-async-storage/async-storage, were hoisted to the root directory, preventing RN from finding it.

Without knowing, attempting to run the app results in a blank screen with no errors logged or crashings whatsoever. Only after hours of debugging did I realize that and not hoisting such packages is the solution.

This is because the native code looks for these dependencies in ../node_modules/x. However, with hoisting, they are moved to ../../node_modules/x.

For app package.json, I only had to add my shared packages into dependencies:

{
"dependencies": {
"shared-a": "^0.0.0",
"shared-b": "^0.0.0"
}
}

metro.config.json

To enable Metro to detect other modules in our monorepo, we need to config watchFolder to include them.

const path = require("path");
module.exports = {
watchFolders: [path.resolve(__dirname, "..")],
};

However, this watches the whole root folder, which is very resource-consuming. If we only want to watch the root node_modules and the other packages in the monorepo, the below will do:

module.exports = {
watchFolders: [
path.resolve(__dirname, "..", "node_modules"),
path.resolve(__dirname, "..", "shared-a"),
path.resolve(__dirname, "..", "shared-b"),
],
};

We can also automate the listing by using the NPM package get-yarn-workspaces.

const getWorkspaces = require("get-yarn-workspaces");
module.exports = {
watchFolders: [
path.resolve(__dirname, "..", "node_modules"),
...getWorkspaces(appDir).filter((workspaceDir) => workspaceDir !== appDir), // list all of the other packages in the monorepo
],
};

If we try to start the app at this point, Metro will be able to resolve our shared packages but will run into the following:

Error: Unable to resolve module x from /path/to/shared-a/y

The problem is that some shared dependencies in shared packages are not found in the shared packages directory's node_modules due to being peerDependencies. The solution is to specify them to resolve in the app's node_modules.

const path = require("path");
module.exports = {
watchFolders: [path.resolve(__dirname, "..")],
resolver: {
extraNodeModules: {
x: path.resolve(__dirname, "node_modules", "x"),
},
},
};

Again, we can also automate the listing using the below script:

const path = require("path");
const packageJson = require("./package.json");
const buildExtraNodeModules = () => {
const extraNodeModules = {};
for (const dependencyName in packageJson.dependencies) {
const pkgPath = path.resolve(__dirname, "node_modules", dependencyName);
if (fs.existsSync(pkgPath)) {
extraNodeModules[dependencyName] = pkgPath;
}
}
return extraNodeModules;
};
module.exports = {
watchFolders: [path.resolve(__dirname, "..")],
resolver: {
extraNodeModules: buildExtraNodeModules(),
},
};

Fixing the issues of peerDependencies definitions

As mentioned earlier, TypeScript definitions for those that are installed as peerDependencies will not be found. There are two solutions to resolve this:

  1. Added them as paths to link to where it is installed (which, in this case, is the main app):
{
"compilerOptions": {
"paths": {
"react-native-reanimated": ["../app/node_modules/react-native-reanimated"]
}
}
}

While this works great, it provides a bad DX and some weird behaviors in TypeScript definition resolution.

  1. Use blacklistRE to exclude the versions of dependencies that are not inside app/node_modules.

Consider this package.json from shared-a.

{
"name": "shared-a",
"version": "0.0.0",
"scripts": {
"build": "tsc && babel src -d dist --extensions \".ts\""
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"devDependencies": {
"react-native-reanimated": "*"
},
"peerDependencies": {
"react-native-reanimated": "*"
}
}

(Note, for packages like react, simple add @types/react to devDependencies is sufficient)

The above is the same package.json found above, but with the dependency also added in devDependencies. By doing so, we can now get the definitions of the dependency but will run into an issue I mentioned above:

Animated node with ID 2 already exists

This is because two versions of react-native-reanimated are being used:

  • shared-a/node_modules/react-native-reanimated
  • app/node_modules/react-native-reanimated

We will need to instruct Metro to exclude the first one by using blacklistRE configuration (thanks to this snippet):

const exclusionList = require("metro-config/src/defaults/exclusionList");
// const blacklist = require('react-native/packager/blacklist'); // for older react-native version
module.exports = {
watchFolders: [path.resolve(__dirname, "..")],
resolver: {
blacklistRE: exclusionList([
/shared-a[\/\\]node_modules[/\\]react-native-reanimated[/\\].*/,
]),
},
};

Again, we can automate the listing with the below script:

const otherPackagesDir = workspaces.filter(
(workspaceDir) => workspaceDir !== appDir
);
const buildBlocklist = () => {
const list = [];
// for every other package, loop through each of their peerDependencies and exclude it
for (const packageDir of otherPackagesDir) {
const packageName = packageDir.substring(packageDir.lastIndexOf("/") + 1); // get the package name
const otherPackageJson = require(path.join(packageDir, "package.json"));
for (const dependencyName in otherPackageJson.peerDependencies) {
list.push(
new RegExp(
`${packageName}[/\\\\]node_modules[/\\\\]${dependencyName}[/\\\\].*`
)
);
}
}
return list;
};
module.exports = {
watchFolders: [path.resolve(__dirname, "..")],
resolver: {
blacklistRE: exclusionList(buildBlocklist()),
},
};

Conclusion

I have applied the above in my first React Native monorepo app Auralous, and it seems to work great so far.

Hopefully, this will help others avoid the trouble that I have gone through in setting up a React Native monorepo.