React, React Native

Code sharing in React Native TypeScript projects

React Native setup in monorepo that works, at last

Among all approaches to solve code sharing (monorepo) of React Native nowadays, mine enables ease in future upgrades by avoiding patching into the internals.


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. 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 not ideal because it requires editing files inside ios and android directories. It makes it difficult to recover if we mess things up and also difficult to upgrade to future react-native versions (perhaps with React Native Upgrade Helper).

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

In each shared package, set up package.json to mirror the following:

{
"name": "shared-a",
"version": "0.0.0",
"scripts": {
"build": "tsc && babel src -d dist --extensions \".ts\""
},
"main": "src/index.ts",
"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 because they are indeed peerDependencies that are available in our mobile and app packages.

Now, this approach comes with an issue and that is TypeScript cannot find the peer dependencies' definitions as they are not installed, so we have to install them as devDependencies too. However, I do not want to pollute my package.json so I create the script below:

// ./shared-a/scripts/install-peers
const { execSync } = require("child_process");
const { writeFileSync, readFileSync } = require("fs");
const path = require("path");
const pkgJsonPath = path.join(__dirname, "..", "package.json");
const packageJsonTxt = readFileSync(pkgJsonPath, {
encoding: "UTF-8",
}).toString();
const packageJson = JSON.parse(packageJsonTxt);
let cmd = `yarn add --dev --frozen-lockfile --ignore-scripts `;
for (const [packageName, packageVer] of Object.entries(
packageJson.peerDependencies
)) {
cmd += `${packageName}@${packageVer} `;
}
console.log(cmd);
execSync(cmd);

The script will install every peer dependency as a dev dependency. You would run this every time a peer dependency is added.

Use a shared package in another shared package

A shared package can include other packages in the workspace, For example, in shared-b, let's add shared-a into dependencies:

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

Configure shared packages tsconfig to work with the main packages

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 like 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

In my setup, the shared packages are not published to NPM, so I do not need to build them. Thus, you may notice I set main to src/index.ts. If you need to build the packages (transpile to JavaScript), change main to dist/index.js and remove "emitDeclarationOnly": true from the shared packages tsconfig.json.

Setting up the mobile app

For starter, let's add into the mobile package.json the shared packages into dependencies:

// ./mobile/package.json
{
"dependencies": {
"shared-a": "^0.0.0",
"shared-b": "^0.0.0"
}
}

Configure tsconfig to include the shared packages

As shared-a and shared-b are composited, we can refer to both of them in path, similar to how shared-b do with shared-a.

// ./mobile/tsconfig.json
{
"compilerOptions": {
"composite": false // this should be false, unlike the other two
},
"references": [
{
"path": "../shared-a"
},
{
"path": "../shared-b"
}
]
}

Setting nohoist configuration

With the default yarn workspace configuration, react-native would run into issues of not being able to find native modules. For example, native modules, such as @react-native-async-storage/async-storage, would be hoisted to the root directory (./node_modules/@react-native-async-storage/async-storage), preventing RN from finding it (which looks in ./mobile/node_modules/@react-native-async-storage/async-storage).

As a workaround, we have to edit the root package.json (not the one in mobile).

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

Using nohoist allows such native modules to stay where it is and avoid further issues (like not having to edit its references inside android and ios folders). Patterns like **/@react-native-*/* and **/react-native-* makes sense because most native modules on node_modules has such naming pattern (if not, you have to list it explicitly).

Config metro to detect changes in shared packages

To enable Metro to detect other modules in our monorepo, we need to config watchFolder to include them. Otherwise, changes in the shared packages would not result in a refresh.

// ./mobile/metro.config.js
const path = require("path");
const currDir = __dirname;
const packageDirs = [];
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:

// ./mobile/metro.config.js
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.

// ./mobile/metro.config.js
const getWorkspaces = require("get-yarn-workspaces");
const rootDir = path.resolve(__dirname, "..");
const currDir = __dirname;
const packageDirs = getWorkspaces(rootDir).filter((dir) => {
return dir !== currDir && dir !== path.join(rootDir, "web"); // we dont want to include itself and the web package
});
module.exports = {
watchFolders: [path.resolve(__dirname, "..", "node_modules"), ...packageDirs],
};

Avoid duplicated dependencies issue

With the nohoist setup, some dependencies would be duplicated, and it would not work especially if it is a native module or react and react-native itself. For example, looking at this error:

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
  • mobile/node_modules/react-native-reanimated

We will need to instruct Metro to exclude the first one by using blockList (note: In older RN versions, this was blacklistRE) configuration. However, if a dependency is listed in blockList and is imported inside our shared packages, metro would not be able to find it (since it is blocked). We would have to specify it to look for the one inside mobile using extraNodeModules.

// ./mobile/metro.config.js
const exclusionList = require("metro-config/src/defaults/exclusionList");
// const blacklist = require('react-native/packager/blacklist'); // for older react-native version
module.exports = {
resolver: {
blockList: exclusionList([
/shared-a[\/\\]node_modules[/\\]react-native-reanimated[/\\].*/,
]),
// blackList: ... for older react-native-version
extraNodeModules: {
"react-native-reanimated":
"/path/to/mobile/node_modules/react-native-reanimated",
},
},
};

To avoid listing them multiple times, we will automate the listing for each peerDependency inside the shared packages:

// ./mobile/metro.config.js
const getWorkspaces = require("get-yarn-workspaces");
const buildModuleLists = () => {
const blockList = [];
const extraNodeModules = {};
// loop through each package dir
for (const dir of packageDirs) {
const dirPackageJson = require(path.join(dir, "package.json"));
for (const depName in dirPackageJson.peerDependencies) {
// force metro to not resolve this version
// and instead the one in "extraNodeModules"
blockList.push(new RegExp(`${dir}/node_modules/${depName}/.*`));
// since the above module would not be found if resolved as it,
// we need to tell it to look for the version that the current package use
// we use require.resolve to look for two places (current node_modules or root node_module)
const resolvedDepFile = require.resolve(depName, {
paths: [currDir, rootDir],
});
// resolvedDepFile is not neccessary the package parent dir of that dep (might be the index.js file, so we use pkgDir to find the, well, package dir)
extraNodeModules[depName] = pkgDir.sync(resolvedDepFile);
}
}
return {
blockList,
extraNodeModules,
};
};
const { blockList, extraNodeModules } = buildModuleLists();
module.exports = {
resolver: {
blockList: exclusionList(blockList),
extraNodeModules,
/* ... */
},
};

Solutions for some issues with this setup

  1. Invariant Violation: No callback found with...

This happens because some dependencies (such as react or react-native) are duplicated. You should add them as a peerDependency on other packages so that they will be picked up by the script and excluded accordingly.

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.