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.
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:
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:
npx react-native init CustomerPortal
Once this command finishes, you should have a functioning React Native project scaffolded in CustomerPortal
folder:
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:
- Move the generated files into a sub-folder of
apps
calledcustomer-portal
. - Run
npm init
at the new root to create apackage.json
- Run
git init
at the new root to create a Git repository to track your code changes - Add a
.gitignore
(you can copy it from your app) at the new root to make sure you're not tracking newnode_modules
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:
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:
corepack enable
Then, you can run the following:
corepack prepare yarn@stable --activate
And finally:
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
:
{ "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:
React Native does not need `nohoist`, in fact, despite widespread opinion `nohoist` makes your project broken. The correct way is to properly configure React Native and tell it about all your `node_modules` locations. This way it will work with npm and yarn just fine.
— Victor Vlasenko (@larixer) September 15, 2022
Build.gradle doesn't seem to (at least for Android) and it's caused some headaches. I'm sure I'll figure it out, but it's just a lot of dev time lost on my end that I'm lamenting (especially when I'm drowning in other stuff at the same time)
— Corbin Crutchley (@crutchcorn) September 15, 2022
Yes, I understand it's a headache. The thing is `nohoist` is a bad solution - you have to micromanage hoisting which is terrible and error prone. What native libs need is just to check multiple node_modules location. Maybe gradle plugin would be a good way to improve DX
— Victor Vlasenko (@larixer) September 15, 2022
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
:
Oh, trust me, I know the micromanaging pains of hoisting plenty. All for better and nicer native DX here pic.twitter.com/Nc24ANvgNS
— Corbin Crutchley (@crutchcorn) September 15, 2022
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:
- Creating a new folder called
packages
and a subfolder calledshared-elements
. - Running
npm init
inside to make a newpackage.json
file. - Create
src/index.tsx
.
Inside of our newly created index.tsx
, let's create a HelloWorld
component:
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:
-
Open your terminal and
cd
intopackages/shared-elements/
-
Install your expected packages using:
yarn add react react-nativeyarn 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:
// This config file is incomplete and will cause bugs at build, read on for moreimport 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:
{ "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:
{ "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...
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.jsfile: /packages/shared-elements/node_modules/node_modules/react-native/index.js:14:712:13: // Components14: 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 require
s of those two libraries:
// vite.config.tsimport 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/
:
{ "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, thepeerDependencies
, and thedevDependencies
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:
{ "/* ... */": "...", "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!
// App.tsximport {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
.
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 require
s 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 yourpeerDep
s, which would quickly bloat and confuse yourpackage.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:
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:
// packages/shared-elements/jest.config.jsconst 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:
// packages/shared-elements/jest/setup-files-after-env.jsimport "@testing-library/jest-native/extend-expect";
// packages/shared-elements/babel.config.jsmodule.exports = { presets: ["module:metro-react-native-babel-preset"]};
Finally, add your test-specific TypeScript configuration file to packages/shared-elements/tsconfig.jest.json
:
{ "extends": "./tsconfig", "compilerOptions": { "types": ["node", "jest"], "isolatedModules": false, "noUnusedLocals": false }, "include": ["**/*.spec.tsx"], "exclude": []}
Now you can write your test:
// packages/shared-elements/src/index.spec.tsximport {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:
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:
module.exports = { preset: "@testing-library/react-native", // ...};
We then follow this up with moduleNameMapper
:
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):
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
:
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
- Starts with
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:
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:
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:
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:
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:
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:
// jest.config.jsmodule.exports = { // Or "@testing-library/react-native" preset: 'react-native',};
This Jest config applies the following rules:
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
:
cd packagesmkdir configcd config
Then, yarn init
a new package:
yarn init
Once done, set the package.json
to have a name of @your-org/config
:
{ 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
orapps
.You can do this by adding:
{ "/* ... */": "...", "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:
{ "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:
{ "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:
{ "/* packages/config/tsconfig.jest.json */ ": "...", "extends": "./tsconfig.json", "compilerOptions": { "types": ["node", "jest"], "isolatedModules": false, "noUnusedLocals": false }, "include": ["**/*.spec.tsx"], "exclude": []}
{ "/* 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:
- Move your
packages/shared-elements/jest.config.js
file intopackages/config/jest.config.js
. - Create a new
packages/shared-elements/jest.config.js
file with:
module.exports = require("@your-org/config/jest.config");
- Profit.
You can even customize the base rules on a per-app basis by doing something akin to the following:
// packages/shared-elements/jest.config.jsconst 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
:
module.exports = { extends: [ "@react-native-community", "plugin:@typescript-eslint/recommended-type-checked", "plugin:prettier/recommended", ], parser: "@typescript-eslint/parser", plugins: ["prettier"], overrides: [ { extends: ["plugin:@typescript-eslint/disable-type-checked"], files: ["./**/*.js"], }, ], 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
:module.exports = { root: true, ...require("./eslint-preset"), parserOptions: { project: true, tsconfigRootDir: __dirname, },};
-
/.eslintrc.js
module.exports = { root: true, ...require("./packages/config/eslint-preset"), parserOptions: { project: true, tsconfigRootDir: __dirname, },};
-
packages/shared-elements/.eslintrc.js
module.exports = { root: true, ...require("@your-org/config/eslint-preset"), parserOptions: { project: true, tsconfigRootDir: __dirname, },};
-
/apps/customer-portal/.eslintrc.js
module.exports = { root: true, ...require("@your-org/config/eslint-preset"), parserOptions: { project: true, tsconfigRootDir: __dirname, },};
Finally, at the root of your project, run:
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.json
s:
{ "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!