How to Setup a React Native Monorepo

June 29, 2023

5,501 words

Post contents

React Native allows you to write React code that outputs to native applications for various platforms, including:

  • Android
  • iOS
  • Windows
  • macOS

It's an undeniably powerful way to share code between web applications and your mobile apps, particularly within small teams that either don't have the knowledge or the capacity to go fully native.

Similarly, monorepos can be a fantastic way to share code between multiple projects with a similar tech stack.

Combined together and even a small team can maintain multiple React Native applications seamlessly.

Two apps: One customer portal and one admin panel extending from shared code. Each portal has a Windows, macOS, Android, and iOS app

Unfortunately, it can be rather challenging to build out a monorepo that properly supports React Native. While Expo supports monorepo usage, one common complaint when using Expo is that Expo does not support many popular React Native libraries that require native code.

To further exacerbate the issue, React Native comes with many uncommon edgecases that make monorepos particularly challenging to create. Many of the tutorials I've found outlining how to build a monorepo for this purpose use outdated tools to work around this.

Knowing just how potent the potential impact of a monorepo would be on my projects, I disregarded these headaches and spent a month or two building out a monorepo that solved my problems.

By the end of it all, I had a monorepo structure that looked something like the following:

  • apps/
    • customer-portal/
      • android/
        • ...
      • ios/
        • ...
      • src
        • App.tsx
        • components/
          • ...
        • hooks/
          • ...
        • utils/
          • ...
        • types/
          • ...
      • .eslintrc.js
      • app.json
      • babel.config.js
      • index.js
      • metro.config.js
      • node_modules
      • package.json
      • tsconfig.json
    • admin-portal/
      • android/
        • ...
      • ios/
        • ...
      • src
        • App.tsx
        • components/
          • ...
        • hooks/
          • ...
        • utils/
          • ...
        • types/
          • ...
      • .eslintrc.js
      • app.json
      • babel.config.js
      • index.js
      • metro.config.js
      • node_modules
      • package.json
      • tsconfig.json
  • packages/
    • config/
      • .eslintrc.js
      • babel-config.js
      • eslint-preset.js
      • package.json
      • tsconfig.json
    • shared-elements/
      • src/
        • components/
          • ...
        • hooks/
          • ...
        • utils/
          • ...
        • types/
          • ...
      • .eslintrc.js
      • package.json
      • vite.config.ts
  • .eslintrc.js
  • .gitignore
  • .yarnrc.yml
  • README.md
  • package.json
  • yarn.lock

I'd like to share how you can do the same in this article. Let's walk through how to:

Set Up a React Native Project

Let's set up a basic React Native project to extend using a monorepo.

Before you get started with this section, make sure you have your environment set up, including XCode/Android Studio.

To set up a basic React Native project from scratch, run the following:

shell
npx react-native init CustomerPortal

Once this command finishes, you should have a functioning React Native project scaffolded in CustomerPortal folder:

  • android/
    • ...
  • ios/
    • ...
  • .eslintrc.js
  • app.json
  • App.tsx
  • babel.config.js
  • index.js
  • metro.config.js
  • node_modules
  • package.json
  • tsconfig.json

We now have a basic demo application that we can extend by adding it to our monorepo.

To start setting up the monorepo, take the following actions:

  1. Move the generated files into a sub-folder of apps called customer-portal.
  2. Run npm init at the new root to create a package.json
  3. Run git init at the new root to create a Git repository to track your code changes
  4. Add a .gitignore (you can copy it from your app) at the new root to make sure you're not tracking new node_modules
  • .git/
    • ...
  • apps/
    • customer-portal/
      • android/
        • ...
      • ios/
        • ...
      • .eslintrc.js
      • app.json
      • App.tsx
      • babel.config.js
      • index.js
      • metro.config.js
      • node_modules
      • package.json
      • tsconfig.json
  • .gitignore
  • package.json

Congrats! You technically now have a monorepo, even if it's currently missing many conveniences of a well-established monorepo.

Maintain Multiple Package Roots with Yarn

Let's imagine that we've taken our newly created monorepo and added a second application inside:

  • apps/
    • customer-portal/
      • package.json
      • ...
    • admin-portal/
      • package.json
      • ...
  • .gitignore
  • package.json

Notice how each of our sub-projects has its own package.json? This allows us to split out our dependencies based on which project requires them rather than having a single global package.json with every project's dependencies in it.

However, without any additional configuration, it means that we need to npm install in every subdirectory manually to get our projects set up.

What if there was a way to have a single install command that installed all packages for all package.json files in our repo? Well, we can!

To do this, we need some kind of "workspace" support, which tells our package manager to install deps from every package.json in our system.

Here are the most popular Node package managers that support workspaces:

While NPM is often reached for as the default package manager for Node apps, it lacks a big feature that's a nice-to-have in large-scale monorepos: Patching NPM packages.

While NPM can use a third-party package to enable this functionality, it has shaky support for monorepos. Compare this to PNPM and Yarn, which both have this functionality built-in for monorepos.

This leaves us with a choice between pnpm and yarn for our package manager in our monorepo.

While pnpm is well loved by developers for its offline functionality, I've had more experience with Yarn and found it to work well for my needs.

Installing Yarn 3 (Berry)

When most people talk about using Yarn, they're often talking about using Yarn v1, which originally launched in 2017. While Yarn v1 works for most needs, I've run into bugs with its monorepo support that halted progress at times.

Here's the bad news: Yarn v1's last release was in 2022 and is in maintenance mode.

Here's the good news: Yarn has continued development with breaking changes and is now on Yarn 3. These newer versions of Yarn are colloquially called "Yarn Berry".

To setup Yarn Berry from your project, you'll need:

  • Node 16 or higher
  • ... That's it.

While there's more extensive documentation on how to install Yarn on their docs pages, you need to enable Corepack by running the following in your terminal:

shell
corepack enable

Then, you can run the following:

shell
corepack prepare yarn@stable --activate

And finally:

shell
yarn set version stable

This will download the yarn-3.x.x.cjs file, configure a .yarnrc.yml file, and add the information required to your package.json file.

Wait! Don't run yarn install yet! We still have some more configuration to do!

Disabling Yarn Plug'n'Play (PNP)

By default, Yarn Berry uses a method of installing your packages called Yarn Plug'n'Play (PNP), which allows you to commit your node_modules cache to your Git repository.

Because of React Native's incompatibility with Yarn PNP, we need to disable it. To do this, we update our .yarnrc.yml file to add:

nodeLinker: node-modules

It's worth mentioning that while PNPM doesn't use PNP as its install mechanism, it does extensively use symlinks for monorepos. If you're using PNPM for your project, you'll likely want to disable the symlinking functionality for your monorepo.

You'll also want to add the following to your .gitignore

.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

On the note of Git, you'll want to commit .yarn/releases/yarn-3.x.x.cjs, as Yarn will not work for your other developers otherwise.

yarn install still won't work yet; keep reading!

Configuring Yarn to Support Monorepos

Now that we've disabled Yarn PNP, we need to configure Yarn to install all deps for all of the projects in our workspace. To do this, add the following to your package.json:

json
{
"name": "@your-org/app-root",
"version": "0.0.1",
"private": true,
"workspaces": {
"packages": [
"apps/*",
"packages/*",
"websites/*"
]
},
"packageManager": "yarn@3.5.1"
}

Replace your-org with an NPM organization that your company owns. Otherwise, you're susceptible with various attacks without this org namespace.

Finally, let's configure yarn to install a fresh version of node_modules for each of our apps so that React Native can easily detect our packages without having to configure Metro to find multiple node_modules. To do that, we'll add a new line to our .yarnrc.yml file:

nmHoistingLimits: workspaces

Congrats! You can now install all of your apps' dependencies using yarn install! πŸŽ‰

A note about nohoist

It's worth mentioning that other React Native monorepo guides often utilize Yarn 1's nohoist functionality, which is no longer supported in Yarn 2+.

Here's what a maintainer of Yarn told me about the possibility of supporting nohoist in Yarn is:

As such, it seems like nohoist won't be seeing a comeback to Yarn. This means that if you have the same package in 3 apps, it will be installed 3 individual times.

This may seem like a bad thing until you realize that you're now free of having to have a package.json with a hundred entries in nohoist:

Package Shared Elements to use Across Apps

Having multiple related apps in the same monorepo is valuable in its own right for colocating your teams' focus, but we can go one step further.

What if we had a way to share code between different apps using a shared package? Let's do this by creating a new package inside of our monorepo called shared-elements.

Start by:

  1. Creating a new folder called packages and a subfolder called shared-elements .
  2. Running npm init inside to make a new package.json file.
  3. Create src/index.tsx.
  • apps/
    • customer-portal/
      • ...
    • admin-portal/
      • ...
  • packages/
    • shared-elements/
      • src/
        • index.tsx
      • package.json
  • .gitignore
  • .yarnrc.yml
  • package.json
  • yarn.lock

Inside of our newly created index.tsx, let's create a HelloWorld component:

tsx
import {Text} from "react-native";
export const HelloWorld = () => {
return <Text>Hello world</Text>
}

At this point, your IDE will likely complain that you don't have react-native or react installed. To fix that:

  1. Open your terminal and cd into packages/shared-elements/

  2. Install your expected packages using:

shell
yarn add react react-native
yarn add -D @types/react @types/react-native typescript

You should now not see any errors in your IDE!

Bundling our Shared Repo with Vite

While our IDE isn't showing any errors, if we attempt to consume our library in our apps right now we'll run into various issues, because we're trying to import .tsx files without turning them into .js files first.

To transform these source files, we need to configure a "Bundler" to take our source code files and turn them into compiled files to be used by our apps.

While we could theoretically use any other bundler, I find that Vite is the easiest to configure and provides the nicest developer experience out-of-the-box.

Using Vite's React plugin and Vite's library mode, we can easily generate .js files for our source code. Combined with vite-plugin-dts, we can even generate .d.ts files for TypeScript to get our typings as well.

Here's what an example vite.config.ts file - placed in /packages/shared-elements/ - might look like:

typescript
// This config file is incomplete and will cause bugs at build, read on for more
import react from "@vitejs/plugin-react";
import * as path from "node:path";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
plugins: [
react(),
dts({
entryRoot: path.resolve(__dirname, "./src"),
}),
],
build: {
lib: {
entry: {
"@your-org/shared-elements": path.resolve(__dirname, "src/index.tsx"),
},
name: "SharedElements",
fileName: (format, entryName) => `${entryName}-${format}.js`,
formats: ["es", "cjs"],
},
},
});

The fileName, formats, and entry files tell Vite to "build everything inside of src/index.tsx into a CommonJS and ES Module file for apps to consume". We then need to update our package.json file (located in /packages/shared-elements/) to tell these apps where to look when importing from this package:

json
{
"name": "@your-org/shared-elements",
"version": "0.0.1",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"tsc": "tsc --noEmit"
},
"types": "dist/index.d.ts",
"main": "./dist/shared-elements-cjs.js",
"module": "./dist/shared-elements-es.js",
"react-native": "./dist/shared-elements-es.js",
"exports": {
".": {
"import": "./dist/shared-elements-es.js",
"require": "./dist/shared-elements-cjs.js",
"types": "./dist/index.d.ts"
}
},
"dependencies": {
"react": "18.2.0",
"react-native": "0.71.7"
},
"devDependencies": {
"@types/react": "^18.2.7",
"@types/react-native": "^0.72.2",
"@vitejs/plugin-react": "^3.1.0",
"typescript": "^4.9.3",
"vite": "^4.1.2",
"vite-plugin-dts": "^2.0.2"
}
}

Finally, we'll add a small tsconfig.json file:

json
{
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"lib": ["es6", "dom"],
"jsx": "react-native",
"strict": true,
"outDir": "dist",
"noEmit": false,
"skipLibCheck": true
}
}

And add a /packages/shared-elements/.gitignore file:

dist/

Now let's run yarn build annnnd...

shell
vite v4.3.3 building for production...
βœ“ 2 modules transformed.
[vite:dts] Start generate declaration files...
βœ“ built in 900ms
[vite:dts] Declaration files built in 743ms.
[commonjs--resolver] Unexpected token (14:7) in /packages/shared-elements/node_modules/react-native/index.js
file: /packages/shared-elements/node_modules/node_modules/react-native/index.js:14:7
12:
13: // Components
14: import typeof AccessibilityInfo from './Libraries/Components/AccessibilityInfo/AccessibilityInfo';
^
15: import typeof ActivityIndicator from './Libraries/Components/ActivityIndicator/ActivityIndicator';
16: import typeof Button from './Libraries/Components/Button';
error during build:
SyntaxError: Unexpected token (14:7) in /packages/shared-elements/node_modules/node_modules/react-native/index.js

Uh oh.

This error is occuring because React Native is written with Flow, which our Vite configuration doesn't understand. While we could fix this by using vite-plugin-babel to parse out the Flow code, we don't want to bundle react or react-native into our shared package anyway.

This is because React (and React Native) expects a singleton where the app only has a single instance of the project. This means that we need to tell Vite not to transform the import and requires of those two libraries:

typescript
// vite.config.ts
import react from "@vitejs/plugin-react";
import * as path from "node:path";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
plugins: [
react(),
dts({
entryRoot: path.resolve(__dirname, "./src"),
}),
],
build: {
lib: {
entry: {
"@your-org/shared-elements": path.resolve(__dirname, "src/index.tsx"),
},
name: "SharedElements",
fileName: (format, entryName) => `${entryName}-${format}.js`,
formats: ["es", "cjs"],
},
rollupOptions: {
external: [
"react",
"react/jsx-runtime",
"react-dom",
"react-native",
"react/jsx-runtime",
],
output: {
globals: {
react: "React",
"react/jsx-runtime": "jsxRuntime",
"react-native": "ReactNative",
"react-dom": "ReactDOM",
},
},
},
},
});

Because these packages aren't included in the bundle anymore, we need to flag to our apps that they need to install the packages as well. To do this we need to utilize devDependencies and peerDependencies in /packages/shared-elements/:

json
{
"name": "@your-org/shared-elements",
"version": "0.0.1",
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"tsc": "tsc --noEmit"
},
"types": "dist/index.d.ts",
"main": "./dist/shared-elements-cjs.js",
"module": "./dist/shared-elements-es.js",
"react-native": "./dist/shared-elements-es.js",
"exports": {
".": {
"import": "./dist/shared-elements-es.js",
"require": "./dist/shared-elements-cjs.js",
"types": "./dist/index.d.ts"
}
},
"peerDependencies": {
"react": "18.2.0",
"react-native": "0.71.7"
},
"devDependencies": {
"@types/react": "^18.2.7",
"@types/react-native": "^0.72.2",
"@vitejs/plugin-react": "^3.1.0",
"react": "18.2.0",
"react-native": "0.71.7",
"typescript": "^4.9.3",
"vite": "^4.1.2",
"vite-plugin-dts": "^2.0.2"
}
}

Any time we add a dependency that relies on React or React Native, we need to add them to the external array, the peerDependencies, and the devDependencies list.

EG: If you add react-native-fs it needs to be added to both and installed in the app's package.

Install the Shared Package

Now that we have our package setup in our monorepo, we need to tell Yarn to associate the package as a dependency of our app. To do this, modify the apps/[YOUR-APP]/package.json file by adding:

json
{
"/* ... */": "...",
"dependencies": {
"@your-org/shared-elements": "workspace:*"
}
}

Now, re-run yarn at the root of the monorepo. This will link your dependencies together as if it were any other, but pulling from your local filesystem!

Using the Package in Our App

Now that we have our package set up, let's use it in our app!

tsx
// App.tsx
import {HelloWorld} from "@your-org/shared-elements";
export const App = () => {
return <HelloWorld/>
}

That's all! πŸŽ‰

But wait... We're hitting some kind of error when we run our app... I wonder if it's becaus...

Fixing issues with the Metro Bundler

Remember how React requires a single instance of React (and React deps) require exactly one single instance of itself in order to operate properly?

Well, not only do we have to solve this issue on the shared-elements package, we also have to update the bundler in our React Native app. This bundler is called Metro and can be configured with a file called metro.config.js.

javascript
const path = require("path");
module.exports = (__dirname) => {
// Live refresh when any of our packages are rebuilt
const packagesWorkspace = path.resolve(
path.join(__dirname, "../../packages")
);
const watchFolders = [packagesWorkspace];
const nodeModulesPaths = [
path.resolve(path.join(__dirname, "./node_modules")),
];
return {
transformer: {
getTransformOptions: async () => ({
transform: {
experimentalImportSupport: true,
inlineRequires: true,
},
}),
},
resolver: {
// "Please use our `node_modules` instance of these packages"
resolveRequest: (context, moduleName, platform) => {
if (
// Add to this list whenever a new React-reliant dependency is added
moduleName.startsWith("react") ||
moduleName.startsWith("@react-native") ||
moduleName.startsWith("@react-native-community") ||
moduleName.startsWith("@your-org")
) {
const pathToResolve = path.resolve(
__dirname,
"node_modules",
moduleName
);
return context.resolveRequest(context, pathToResolve, platform);
}
// Optionally, chain to the standard Metro resolver.
return context.resolveRequest(context, moduleName, platform);
},
nodeModulesPaths,
},
watchFolders,
};
};

Without this additional configuration, Metro will attempt to resolve the import and requires of the shared-elements package from /packages/shared-elements/node_modules instead of /apps/your-app/node_modules, which leads to a non-singleton mismatch of React versions.

This means that any time you add a package that relies on React, you'll want to add it to your resolveRequest conditional check.

Without this if check, you're telling Metro to search for all dependencies from your app root. While this might sound like a good idea at first, it means that you'll have to install all subdependencies of your projects as well as your peerDeps, which would quickly bloat and confuse your package.json.

Add Testing to our Monorepo with Jest

While I'm not an avid fan of Test-Driven-Development, it's hard to argue that testing doesn't make a massive impact to the overall quality of the end-result of a codebase.

Let's set up Jest and Testing Library to write fast and easy to read integration tests for our applications.

While you could add end-to-end testing with something like Detox or Maestro, I find that integration testing is often a better fit for most apps.

While we'll eventually add testing to all of our apps and packages, let's start by adding testing to our shared-elements package.

Install the following packages:

shell
yarn add -D jest @testing-library/react-native @testing-library/jest-native babel-jest ts-jest @types/jest react-test-renderer

This will enable usage of Testing Library and all the deps you'll need for Jest. Jest can then be configured using a jest.config.js file:

javascript
// packages/shared-elements/jest.config.js
const path = require("path");
module.exports = {
preset: "@testing-library/react-native",
moduleNameMapper: {
"^react$": "<rootDir>/node_modules/react",
},
setupFilesAfterEnv: [
path.resolve(__dirname, "./jest/setup-files-after-env.js"),
],
transform: {
"^.+\\.jsx$": [
"babel-jest",
{ configFile: path.resolve(__dirname, "./babel.config.js") },
],
"^.+\\.tsx?$": [
"ts-jest",
{
babelConfig: path.resolve(__dirname, "./babel.config.js"),
tsconfig: path.resolve(__dirname, "./tsconfig.jest.json"),
},
],
},
transformIgnorePatterns: [
"node_modules/(?!((jest-)?react-native(.*)?|@react-native(-community)?)/)",
],
testPathIgnorePatterns: ["/node_modules/", "dist/"],
};

And providing the needed configuration files:

javascript
// packages/shared-elements/jest/setup-files-after-env.js
import "@testing-library/jest-native/extend-expect";
javascript
// packages/shared-elements/babel.config.js
module.exports = {
presets: ["module:metro-react-native-babel-preset"]
};

Finally, add your test-specific TypeScript configuration file to packages/shared-elements/tsconfig.jest.json:

json
{
"extends": "./tsconfig",
"compilerOptions": {
"types": ["node", "jest"],
"isolatedModules": false,
"noUnusedLocals": false
},
"include": ["**/*.spec.tsx"],
"exclude": []
}

Now you can write your test:

tsx
// packages/shared-elements/src/index.spec.tsx
import {HelloWorld} from "./index";
import {render} from "@testing-library/react-native";
test("Says hello", () => {
const {getByText} = render(<HelloWorld/>);
expect(getByText("Hello world")).toBeDefined();
})

And you should see a passing test when running:

shell
yarn jest # Run this inside /packages/shared-elements

πŸŽ‰

If you get the following error message when trying to run your tests:

FAIL src/index.spec.tsx
● Test suite failed to run
Configuration error:
Could not locate module react mapped as:
/packages/shared-elements/node_modules/react.
Please check your configuration for these entries:
{
"moduleNameMapper": {
"/^react$/": /packages/shared-elements/node_modules/react"
},
"resolver": undefined
}

Make sure that you didn't forget to add the following to your root .yarnrc.yml file:

nmHoistingLimits: workspaces

Whoa... That moved a little fast... Let's stop and take a look at that jest.config.js file again and explain each section of it.

Dissecting the Jest Config File

First, in our Jest config file, we're telling Jest that it needs to treat our environment as if it were a React Native JavaScript runtime:

javascript
module.exports = {
preset: "@testing-library/react-native",
// ...
};

We then follow this up with moduleNameMapper:

javascript
module.exports = {
// ...
moduleNameMapper: {
"^react$": "<rootDir>/node_modules/react",
},
// ...
};

Which acts similarly to Vite or Webpack's alias field, telling Jest that "whenever one of these regexes is matched, resolve the following package instead".

This moduleNameMapper allows us to make sure that each React dependency/subdependency is resolved to a singleton, rather than at the per-package path. This is less important right now with our base shared-elements package, and more relevant when talking about Jest usage in our apps.

Because of this singleton aspect, we need to make sure that we're adding each React sub-dependant to this moduleNameMapper when a new package is installed.


Next up is the transform key, which allows us to use TypeScript and .tsx files for our tests, as well as telling Jest to transform .js and .jsx files to handle React Native specific rules (more on that soon):

javascript
module.exports = {
// ...
transform: {
"^.+\\.jsx$": [
"babel-jest",
{ configFile: path.resolve(__dirname, "./babel.config.js") },
],
"^.+\\.tsx?$": [
"ts-jest",
{
babelConfig: path.resolve(__dirname, "./babel.config.js"),
tsconfig: path.resolve(__dirname, "./tsconfig.jest.json"),
},
],
},
// ...
};

Finally, we have our transformIgnorePatterns and testPathIgnorePatterns:

javascript
module.exports = {
// ...
transformIgnorePatterns: [
"node_modules/(?!((jest-)?react-native(.*)?|@react-native(-community)?)/)",
],
testPathIgnorePatterns: ["/node_modules/", "dist/"],
};

These tell Jest to "transform everything except for these folders and files". You'll notice that we have a strange regex in tranformIgnorePatterns:

node_modules/(?!((jest-)?react-native(.*)?|@react-native(-community)?)/)

This regex is saying:

  • Ignore node_modules unless the next part of the path:
    • Starts with jest-react-native
    • Starts with react-native
    • Is a @react-native org package
    • Is a @react-native-community org package

You can learn more about reading and writing regex from my regex guide!

Its purpose is to tell Jest that it should actively transform these non-ignored packages with ts-jest and babel-jest. See, both of them run babel over their respective source code files, which allows for things like:

  • import usage (Jest only supports CommonJS)
  • JSX usage
  • Newer ECMAScript usage than your Node version might support

Or anything else configured in your babel.config.js file.

As such, you'll need to add to this regex when you add a package that's:

  • Using non-transformed JSX, as many React Native packages do
  • ESM only

How to Debug Common Issues with Jest

While using Jest in a React Native monorepo as this can feel like a superpower, it comes with more risks of difficult-to-debug solutions as well.

Here are just a few we've discovered along the way:

Invalid Default Export Issues

Every once in a while, while working on a Jest test, I get the following error:

console.error
Warning: React.jsx: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: object.
Check the render method of `de`.
at Pn (/packages/shared-elements/dist/shared-elements-cjs.js:37:18535)

With the error pointing to some code like so:

typescript
import SomePackage from "some-package";

Alternatively, if I pass that same component usage to styled-components, that error turns into:

FAIL src/screens/Documents/EventDocuments/EventDocumentsScreen.spec.tsx
● Test suite failed to run
Cannot create styled-component for component: [object Object].
956 | width: 100%;
957 | align-items: center;
> 958 | `,OC=

This happens because Jest handles ESM in particularly poor ways and, along the way of compiling into CJS exports, can get confused and often needs help figuring out when something is default exported or not.

To solve these issues, you can hack around Jest's default export detection:

javascript
jest.mock("some-package", () => {
const pkg = jest.requireActual(
"some-package"
);
return Object.assign(pkg.default, pkg);
});

Similarly, If you run into the following error while using styled components:

shell
FAIL src/screens/More/MoreHome/MoreHomeScreen.spec.tsx
● Test suite failed to run
TypeError: w is not a function
> 1 | "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const c=require("react/jsx-runtime"),g=require("react-native"),w=require("styled-components/native"),B=require("react"),fe=require("react-native-elements"),re=require("@fortawesome/react-native-fontawesome"),Ce=require("styled-components"),s6=require("react-native-phone-call"),v0=require("react-native-geocoding"),Zt=require("@reduxjs/toolkit"),nr=require("aws-amplify"),Ii=require("@react-native-async-storage/async-storage"),p0=require("axios"),m0=require("react-redux"),Tu=require("react-native-maps"),se=require("@tanstack/react-query"),Mu=require("@react-native-clipboard/clipboard"),l6=require("@fortawesome/react-fontawesome"),Br=require("react-native-webview"),Za=require("react-native-actionsheet"),Ga=require("react-native-image-picker"),Fr=require("react-native-pager-view"),Pi=require("react-native-actions-sheet"),c6=require("react-native-share"),u6=require("react-native-fs"),go=require("react-native-gesture-handler"),x0=require("react-native-email-link");function d6(e){const t=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(e){for(const r in e)if(r!=="default"){const a=Object.getOwnPropertyDescriptor(e,r);Object.defineProperty(t,r,a.get?a:{enumerable:!0,get:()=>e[r]})}}return t.default=e,Object.freeze(t)}const Rt=d6(B),f6=w(g.View)`

It's fixed by:

typescript
jest.mock("styled-components", () => {
const SC = jest.requireActual("styled-components");
return Object.assign(SC.default, SC);
});

As styled-components falls under the same problems.

Unexpected Token Issues

If you run into an error like so:

FAIL src/screens/SomeScreen.spec.tsx
● Test suite failed to run
Jest encountered an unexpected token
Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.
Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration.
By default "node_modules" folder is ignored by transformers.
Here's what you can do:
β€’ If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it.
β€’ If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript
β€’ To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
β€’ If you need a custom transformation specify a "transform" option in your config.
β€’ If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.
You'll find more details and examples of these config options in the docs:
https://jestjs.io/docs/configuration
For information about custom transformations, see:
https://jestjs.io/docs/code-transformation
Details:
/path/node_modules/@fortawesome/react-native-fontawesome/index.js:1
({"Object.<anonymous>":function(module,exports,require,__dirname,__filename,jest){export { default as FontAwesomeIcon } from './dist/components/FontAwesomeIcon'
^^^^^^
SyntaxError: Unexpected token 'export'

It's caused by forgetting to include the package in question in your transformIgnorePatterns array:

javascript
transformIgnorePatterns: [
"node_modules/(?!((jest-)?react-native(.*)?|@react-navigation|@react-native(-community)?|axios|styled-components|@fortawesome)/)",
],

To explain further, it's due to ESM or JSX being used inside of a package that Jest doesn't know how to handle. By adding it to the array, you're telling Jest to transpile the package for Jest to safely use first.

No Context Value/Invalid Hook Call/Cannot Find Module

This is a three-for-one issue: If you forget to pass a package to moduleNameMapper, Jest won't properly create a singleton of the package (required for React to function properly) and will throw an error.

For example, if you don't link react in moduleNameMapper, you'll get:

FAIL src/screens/SomeScreen.spec.tsx (29.693 s)
● Console
console.error
Warning: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.

Similarly if you forget to link react-redux, you'll get:

could not find react-redux context value; please ensure the component is wrapped in a <Provider>

Or, if you're trying to mock a module that isn't linked, you'll get:

FAIL src/screens/SomeScreen.spec.tsx
● Test suite failed to run
Cannot find module 'react-native-reanimated' from '../../packages/config/jest/setup-files-after-env.js'
24 | jest.mock("react-native-safe-area-context", () => mockSafeAreaContext);
25 |
> 26 | jest.mock("react-native-reanimated", () => {
| ^
27 | // eslint-disable-next-line @typescript-eslint/no-var-requires
28 | const Reanimated = require("react-native-reanimated/mock");
29 |
at Resolver._throwModNotFoundError (node_modules/jest-resolve/build/resolver.js:427:11)
at Object.mock (../../packages/config/jest/setup-files-after-env.js:26:6)
Libraries/Image/Image

If you get the following error:

FAIL src/screens/SomeScreen.spec.tsx
● Test suite failed to run
Cannot find module '../Libraries/Image/Image' from 'node_modules/react-native/jest/setup.js'

You forgot to add the following preset to your shared Jest config:

javascript
// jest.config.js
module.exports = {
// Or "@testing-library/react-native"
preset: 'react-native',
};

This Jest config applies the following rules:

javascript
module.exports = {
haste: {
defaultPlatform: 'ios',
platforms: ['android', 'ios', 'native'],
},
// ...
}

Which tells Jest to find files with those prefixes in the following order:

[file].ios.js
[file].android.js
[file].native.js

You can also solve this issue by adding in the defaultPlatform string and platforms array to your config.

Could Not Find react-dom

Similarly, if you get:

FAIL src/screens/SomeScreen.spec.tsx
● Test suite failed to run
Cannot find module 'react-dom' from 'node_modules/react-redux/lib/utils/reactBatchedUpdates.js'
Require stack:
node_modules/react-redux/lib/utils/reactBatchedUpdates.js
node_modules/react-redux/lib/index.js

It's because you're not adding "native" to the platforms' array from above and only have android and ios in it.

Sharing Configuration Files between Apps

A monorepo doesn't mean much if you can't share configuration files between the apps! This allows you to keep consistent sets of rules across your codebases.

Let's take a look at two of the most popular tools to do this:

  • TypeScript
  • ESLint
  • Jest

Setting up the config package

We'll once again set up a new package to share our configuration files: @your-org/config.

To do this, cd into packages, and make a new directory called config:

shell
cd packages
mkdir config
cd config

Then, yarn init a new package:

shell
yarn init

Once done, set the package.json to have a name of @your-org/config:

json
{
name: '@your-org/config',
packageManager: 'yarn@3.2.3'
}

Now we're off to the races!

Don't forget to install this package in your other packages or apps.

You can do this by adding:

json
{
"/* ... */": "...",
"devDependencies": {
"@your-org/config": "workspace:*"
}
}

And running yarn at the root.

Enforce Consistent TypeScript Usage with tsconfig

Start by creating a tsconfig file in your packages/config directory:

json
{
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"lib": ["es6", "dom"],
"allowJs": true,
"jsx": "react-native",
"noEmit": true,
"isolatedModules": true,
"strict": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["node"]
},
"exclude": [
"node_modules",
"babel.config.js",
"metro.config.js",
"jest.config.js",
"../../**/dist/**/*",
"../../**/*.spec.tsx",
"../../**/*.spec.ts"
]
}

Your tsconfig file may look different from this, that's OK! This is just for an example.

You can then use this as the basis for your apps in your apps/customer-portal/tsconfig.json file:

json
{
"extends": "@your-org/config/tsconfig.json"
}
Jest TSConfig

You can even create a Jest configuration that extends the base config and is then used in your apps:

json
{
"/* packages/config/tsconfig.jest.json */ ": "...",
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["node", "jest"],
"isolatedModules": false,
"noUnusedLocals": false
},
"include": ["**/*.spec.tsx"],
"exclude": []
}
json
{
"/* apps/customer-portal/tsconfig.jest.json */ ": "...",
"extends": "@your-org/config/tsconfig.jest.json",
}

Jest Shared Config

Speaking of Jest, to get a shared configuration working for Jest in your apps and packages:

  1. Move your packages/shared-elements/jest.config.js file into packages/config/jest.config.js.
  2. Create a new packages/shared-elements/jest.config.js file with:
javascript
module.exports = require("@your-org/config/jest.config");
  1. Profit.

You can even customize the base rules on a per-app basis by doing something akin to the following:

javascript
// packages/shared-elements/jest.config.js
const jestConfig = require("@your-org/config/jest.config");
module.exports = {
...jestConfig,
moduleNameMapper: {
...jestConfig.moduleNameMapper,
"^react-native$": "<rootDir>/node_modules/react-native",
},
};

Lint Your Apps with ESLint

To create a base ESLint configuration you can use in all of your apps, start by creating a eslint-preset.js file in packages/config:

javascript
module.exports = {
extends: [
"@react-native-community",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
],
parser: "@typescript-eslint/parser",
plugins: ["prettier"],
rules: {
"no-extra-boolean-cast": "off",
"react/react-in-jsx-scope": "off",
"@typescript-eslint/no-empty-function": "off",
},
};

We're using Prettier here, but you don't have to if you don't wish to!

Then, create .eslintrc.js files in:

  • packages/config/.eslintrc.js:

    javascript
    module.exports = require("./eslint-preset");
  • /.eslintrc.js

    javascript
    module.exports = require("./packages/config/eslint-preset");
  • packages/shared-elements/.eslintrc.js

    javascript
    module.exports = require("@your-org/config/eslint-preset");
  • /apps/customer-portal/.eslintrc.js

    javascript
    module.exports = require("@your-org/config/eslint-preset");

Finally, at the root of your project, run:

shell
yarn add -W -D @typescript-eslint/parser @typescript-eslint/eslint-plugin @react-native-community/eslint-config eslint eslint-config-prettier eslint-config-react-app eslint-plugin-prettier prettier

And add in the linting scripts to your apps' and packages' package.jsons:

json
{
"scripts": {
"lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'",
"format": "eslint 'src/**/*.{js,jsx,ts,tsx}' --fix",
}
}

Next Stop: The Web and Beyond

That's it! You now have a fully functional monorepo!

You may want to work to add Nx, Lerna, or Turborepo to make dependency script management easier, but those tend to be simple to add to existing monorepos after-the-fact - we'll leave that as homework for you to do! πŸ˜‰

Want to see what a final version of this monorepo might look like? Check out my monorepo example package that integrates all of these tools and more!


The next article in the series will showcase how you can use Vite to add a web-based portal to the project using the same codebase to run on both mobile and web architectures.

Until next time - happy hacking!

Creative Commons License

Subscribe to our newsletter!

Subscribe to our newsletter to get updates on new content we create, events we have coming up, and more! We'll make sure not to spam you and provide good insights to the content we have.