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-peersconst { 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.jsconst 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.jsmodule.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.jsconst 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.jsconst 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.jsconst 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
- 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.