Creating a React Native monorepo with shared components using TypeScript
To start we should ask a simple question: why should I use a monorepo for React Native apps? It's actually very useful when you have two (or more) similar applications. Monorepos enable sharing of components, functions and logic between all of the included applications. This can unlock a variety of benefits like core sharing, simplified ci/cd process and consistent tooling.
Setting up workspaces with Yarn
When creating a monorepo for react native applications, it is better to avoid using nohoist because we don't need duplicates inside the root level and inside the project level node_modules
.
Project structure
Our projects need to be divided into subfolders. We treat them as separate entities. Here’s an example of how our project structure can look like:
my-app/
packages/
app1/ <- first app
app2/ <- second app
shared/ <- folder for shared components, logic, etc.
Creating a package.json file in the root directory
- open your terminal and go to the root directory
cd my-app
, - create a package.json file with
yarn init -y
, - open and check your package.json file. It should look something like this:
{
"private": "true",
"name": "my-app",
"version": "1.0.0",
"workspaces": [
"packages/*"
]
}
- Private: true is required. That's very important because we don't want to make our workspaces public.
- Name: the name of your monorepo.
- Workspaces: our workspace with the projects structured in a tree. For now, it's okay as is.
Creating React Native projects
To create React Native projects we can use the following commands:
cd packages
npx react-native init FirstApp --directory app1 --template react-native-template-typescript
npx react-native init SecondApp --directory app2 --template react-native-template-typescript
If your app names and directories match you can use this instead:
cd packages
npx react-native init app1 --template react-native-template-typescript
npx react-native init app2 --template react-native-template-typescript
where app1
and app2
are the names of both our apps and their directories.
Most likely your apps have now been created but pods will not install. No worries, we’ll fix this in the next step:
IOS configuration
- Open
Podfile
go into your app and change paths to thenode_modules
- require_relative '../node_modules/react-native/scripts/react_native_pods'
- require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
+ require_relative '../../../node_modules/react-native/scripts/react_native_pods'
+ require_relative '../../../node_modules/@react-native-community/cli-platform-ios/native_modules'
- Open your terminal and install pods. If you are in the packages/app* folder run:
cd ios
pod install
- Open XCode and your project, select the Project navigator and click on the name of your project. This will open project settings.
- Click
Build Phases
, openBundle React Native code and images
and change the script paths:
- WITH_ENVIRONMENT="../../node_modules/react-native/scripts/xcode/with-environment.sh"
- REACT_NATIVE_XCODE="../../node_modules/react-native/scripts/react-native-xcode.sh"
+ WITH_ENVIRONMENT="../../../node_modules/react-native/scripts/xcode/with-environment.sh"
+ REACT_NATIVE_XCODE="../../../node_modules/react-native/scripts/react-native-xcode.sh"
!Remember to repeat this for each app in your monorepo!
Android setup
For React Native <= 0.71
- Open
myapp/packages/app1/android/build.gradle
and change the paths tonode_modules
allprojects {
repositories {
maven {
// All React Native (JS, Obj-C sources, Android binaries) is installed from npm
- url("$rootDir/../node_modules/react-native/android")
+ url("$rootDir../../../../node_modules/react-native/android")
}
maven {
// Android JSC is installed from npm
- url("$rootDir/../node_modules/jsc-android/dist")
+ url("$rootDir../../../../node_modules/jsc-android/dist")
}
mavenCentral {
// We don't want to fetch react-native from Maven Central as there are
// older versions there.
content {
excludeGroup "com.facebook.react"
}
}
google()
maven { url 'https://www.jitpack.io' }
}
}
- Open
myapp/packages/app1/android/settings.gradle
and change the paths tonode_modules
rootProject.name = 'FirstApp'
- apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle");
+ apply from: file("../../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle");
applyNativeModulesSettingsGradle(settings)
include ':app'
- includeBuild('../node_modules/react-native-gradle-plugin')
+ includeBuild('../../../node_modules/react-native-gradle-plugin')
if (settings.hasProperty("newArchEnabled") && settings.newArchEnabled == "true") {
include(":ReactAndroid")
- project(":ReactAndroid").projectDir = file('../node_modules/react-native/ReactAndroid')
+ project(":ReactAndroid").projectDir = file('../../../node_modules/react-native/ReactAndroid')
include(":ReactAndroid:hermes-engine")
- project(":ReactAndroid:hermes-engine").projectDir = file('../node_modules/react-native/ReactAndroid/hermes-engine')
+ project(":ReactAndroid:hermes-engine").projectDir = file('../node_modules/react-native/ReactAndroid/hermes-engine')
}
- Open
myapp/packages/app1/android/app/build.gradle
and override the default location of the cli
project.ext.react = [
enableHermes: true, // clean and rebuild if changing
+ cliPath: "../../../../node_modules/react-native/cli.js",
]
In the same file change these lines:
- apply from: "../../node_modules/react-native/react.gradle"
+ apply from: "../../../../node_modules/react-native/react.gradle"
- apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
+ apply from: file("../../../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
if (isNewArchitectureEnabled()) {
// We configure the CMake build only if you decide to opt-in for the New Architecture.
externalNativeBuild {
cmake {
arguments "-DPROJECT_BUILD_DIR=$buildDir",
- "-DREACT_ANDROID_DIR=$rootDir/../node_modules/react-native/ReactAndroid",
+ "-DREACT_ANDROID_DIR=$rootDir/../../node_modules/react-native/ReactAndroid",
- "-DREACT_ANDROID_BUILD_DIR=$rootDir/../node_modules/react-native/ReactAndroid/build",
+ "-DREACT_ANDROID_BUILD_DIR=$rootDir/../../node_modules/react-native/ReactAndroid/build",
- "-DNODE_MODULES_DIR=$rootDir/../node_modules",
+ "-DNODE_MODULES_DIR=$rootDir/../../node_modules",
"-DANDROID_STL=c++_shared"
}
}
if (!enableSeparateBuildPerCPUArchitecture) {
ndk {
abiFilters (*reactNativeArchitectures())
}
}
}
For React Native >= 0.71
- Go to
myapp/packages/app1/android/app/build.gradle
- Find and edit the following:
react {
hermesCommand = "../../node_modules/react-native/sdks/hermesc/%OS-BIN%/hermesc"
}
- Go to
myapp/packages/app1/android/build.gradle
and add
allprojects {
project.pluginManager.withPlugin("com.facebook.react") {
react {
reactNativeDir = rootProject.file("../../../node_modules/react-native/")
codegenDir = rootProject.file("../../../node_modules/react-native-codegen/")
}
}
}
- Go to the
myapp/packages/app1/andoird/settings.gradle
and add
apply from: file("../../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
includeBuild('../../../node_modules/react-native-gradle-plugin')
!Remember to repeat this for every app in your monorepo!
Metro configuration
Now we should indicate for metro where node_modules
are located. Go to the metro.config.js
file and add watchFolders
:
const path = require("path");
module.exports = {
watchFolders: [
path.resolve(__dirname, '../../node_modules')
],
};
Shared package
Inside the shared folder, create a package.json
file.
cd shared
yarn init -y
Your package.json
file should look something like this:
{
"name": "@my-app/shared",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"build": "rimraf dist && tsc",
"postinstall": "yarn build",
"watch": "tsc --watch"
},
"dependencies": {
"react-native": "0.69.1",
"react": "18.0.0"
},
"devDependencies": {
"rimraf": "^3.0.2",
"typescript": "^4.4.4"
},
"keywords": [],
"author": ""
}
Close your package.json
and now let's create a tsconfig.json
file:
{
"compilerOptions": {
"target": "es6",
"lib": ["dom", "dom.iterable", "esnext"],
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "commonjs",
"outDir": "dist",
"moduleResolution": "node",
"resolveJsonModule": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"declaration": true,
"jsx": "react",
"baseUrl": "./src"
},
}
Now go into your shared folder, and create an src folder there. Its structure should look something like mine:
src/ index.ts compontents/ index.ts atoms/ index.ts Test.tsx
Where Test.tsx
is a React Function component :)
Now, we can export this in index.ts
in a shared
folder via:
export * from './src'
Now let's create a build of our shared
directory:
yarn build
With that, we have finished setting up our shared
folder. Now we should indicate this directory to our applications. So now we need to add our shared
dependency to the package.json
file in all our applications:
"dependencies" : {
"@my-app/shared": "^1.0.0"
}
and for metro.config.js
:
module.exports = { watchFolders: [ path.resolve(__dirname, '../../node_modules'), path.resolve(__dirname, '../../node_modules/@my-app/shared'), ], };
Then go back into your root directory and run:
yarn
Now we should be able to import things from shared
directly into our React Native Apps:
import React, {type PropsWithChildren} from 'react';
import {
SafeAreaView,
ScrollView,
StatusBar,
StyleSheet,
Text,
useColorScheme,
View,
} from 'react-native';
import {
Colors,
DebugInstructions,
Header,
LearnMoreLinks,
ReloadInstructions,
} from 'react-native/Libraries/NewAppScreen';
import {Test} from '@my-app/shared';
const Section: React.FC<
PropsWithChildren<{
title: string;
}>
> = ({children, title}) => {
const isDarkMode = useColorScheme() === 'light';
return (
<View style={styles.sectionContainer}>
<Text
style={[
styles.sectionTitle,
{
color: isDarkMode ? Colors.white : Colors.black,
},
]}>
{title}
</Text>
<Text
style={[
styles.sectionDescription,
{
color: isDarkMode ? Colors.light : Colors.dark,
},
]}>
{children}
</Text>
</View>
);
};
const App = () => {
const isDarkMode = useColorScheme() === 'dark';
const backgroundStyle = {
backgroundColor: !isDarkMode ? Colors.darker : Colors.lighter,
};
return (
<SafeAreaView style={backgroundStyle}>
<StatusBar
barStyle={!isDarkMode ? 'light-content' : 'dark-content'}
backgroundColor={backgroundStyle.backgroundColor}
/>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
style={backgroundStyle}>
<Header />
<Test />
<View
style={{
backgroundColor: !isDarkMode ? Colors.black : Colors.white,
}}>
<Section title="Step One">
Edit <Text style={styles.highlight}>App.tsx</Text> to change this
screen and then come back to see your edits.
</Section>
<Section title="See Your Changes">
<ReloadInstructions />
</Section>
<Section title="Debug">
<DebugInstructions />
</Section>
<Section title="Learn More">
Read the docs to discover what to do next:
</Section>
<LearnMoreLinks />
</View>
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
sectionContainer: {
marginTop: 32,
paddingHorizontal: 24,
},
sectionTitle: {
fontSize: 24,
fontWeight: '600',
},
sectionDescription: {
marginTop: 8,
fontSize: 18,
fontWeight: '400',
},
highlight: {
fontWeight: '700',
},
});
export default App;
And that’s how it works! Enjoy!