Creating Monorepo using Lerna
Lerna + npm
Note — This Blog explains the monorepo creation from scratch using Lerna and focuses on some of the basic issues you can face during the initial setup like —
- Error: 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: Even you are not using the hooks, when using CRA in one package and other react based UI libraries(Material UI, Reactstrap, etc) in other package
- Failed to compile the React code of other package in CRA package when importing the code from src folder directly, instead of transpiling it to ES5 and shared from lib or build folder
You can find the reason of above errors and the fix later in this blog.
In upcoming blogs, we will see how yarn workspaces or lerna hoisting way help us to manage dependencies in better way
Quick Overview -
Monorepo In a simple language it is nothing but a strategy where all your multiple projects are stored in the single repository instead of handling individual repositories. Let’s say suppose you have an organization where you have different web apps that uses the same style guide, similar configurations, utilities, etc. So without monorepo, we can manage these things in below ways -
- Maintains all those similar things in all the web apps (i.e. in short duplicating code)
- Separate repositories for all those or creating a single repository containing all those.
Above both ways is little cumbersome to manage in terms of unit testing, version maintenance. It slows down the development as well, because while doing development you need to create a symlink so that if you update in one of the repository, it gets reflected to another repository.
With monorepo these all above issues get addressed with some additional advantages as well which you can read more at — https://en.wikipedia.org/wiki/Monorepo
To use this monorepo strategy, there are many ways like -
But what currently used by good open-source projects like babel, storybook, create react app, etc is Lerna with the combination of yarn workspaces.
In this blog, we will keep it simple and understand the Lerna only with npm to manage our monorepo. In upcoming blogs, I will explain how we can use lerna together with yarn.
Let’s take a scenario where a repository consists of below things-
- library which maintains all our re-usable components like Button, TextField, etc. We will use a storybook to maintain all this.
- webapp application which uses the library for its rendering. We use CRA(i.e. create react app) for quick understanding
Enough talk I guess, Let’s understand the above scenario with the code from scratch -
1)Install Lerna (I am installing globally to keep it simple, you can install locally as well)
npm i -g lerna
2)Initialize Lerna
lerna init
It creates 3 things shown below
- lerna.json — used by lerna to get the packages details and other stuff [File]
- package.json — Package json for the complete monorepo node project [File]
- packages — we will add web apps and library inside it [Folder]
Note- It is not mandatory to keep the name as packages, we can change as per our need and same need to be informed to lerna by changing in the lerna.json. Read more at — https://github.com/lerna/lerna/tree/master/commands/init#readme
3)Creating library
lerna create library
It will ask details for package.json. Give your details and it will create below folder structure
Based on the details I have entered, below is the library/package.json file for this blog
3.1)Let’s install storybook for generating components in isolation and create .storybook folder for its configuration. You can refer my blog for storybook basics
lerna add @storybook/react —-scope=@my-project/library
or by command
lerna add @storybook/react packages/library
Note — Both the above commads are same just different ways of using the lerna add command.
1st command — we are using scope option to restrict the add command to run for given packages only. By default it runs for all the packages. Make sure to give node module name(i.e defined in package.json name key) while using scope option which is @my-project/library in our
2nd command — we have mention package name to restrict it for specific package. Here the name is the folder name inside the packages folder which is library in our case
Read more at — https://github.com/lerna/lerna/tree/master/commands/add#readme
3.2)Install react and react-dom as this is required for the storybook
lerna add react packages/librarylerna add react-dom packages/library
3.3)Installing babel-loader and @babel/core required by storybook as a dev dependency used by storybook internally
lerna add babel-loader packages/library —-devlerna add @babel/core packages/library —-dev
3.4)Install material UI library for all built-in components and on top of which we will write our wrapper and use in other packages
lerna add @material-ui/core packages/library
3.5)Let’s create some basic components under the src folder. You can refer the git repo for complete code, below is the basic snippet for actual code and storybook code
// src/components/button/index.jsimport React from “react”;
import MatButton from “@material-ui/core/Button”;const Button = ({type,disabled,children}) =>
<MatButton
variant=”contained”
color={type}
disabled={disabled}>
{children}
</MatButton>export default Button;
// src/components/button/index.stories.jsimport React from “react”;
import Button from “.”;export default {
title:”Button”
}export const basicButton = ()=> <Button> Basic Button</Button>export const coloredButton = ()=>
<>
<Button type=”primary”> Primary Button</Button>
<Button type=”secondary”> Secondary Button</Button>
</>export const disabledButton = ()=>
<Button disabled > Primary Button</Button>
3.6)Now to transpile the code in ES5 under the lib folder so that we can use it in webapp. Install babel CLI and babel presets for react
lerna add @babel/cli packages/library —- devlerna add @babel/preset-env packages/library —- devlerna add @babel/preset-react packages/library —- dev
3.7)Add below scripts to storybook and transpile the code in the lib folder and create .bablerc file to use the presets
// library/package.json scripts
“storybook”: “start-storybook -p 9009”“build-lib”:”babel src — out-dir lib”
// library/.babelrc file{
“presets”: [“@babel/preset-env”,”@babel/preset-react”]
}
1st Script — Runs storybook in port 9009 show all the stories we have created as below. npm run storybook
2nd Script — Transpiles the react code to ES5 using .bablerc file. npm run build-lib
4)Now let’s create our webapp. For this let’s not use the lerna create command instead we will use CRA(create react app)
// Installing CRA globally
npm i -g create-react-app// Go to folder packages
create-react-app webapp
4.1)After installation, go to webapp/package.json file and manually add below dependency to use the library package
"@my-project/library": "1.0.0",
4.2)Run below lerna bootstrap command to inform lerna about this dependency
lerna bootstrap
4.3)Now open wepapp/src/app.js file and import the component from the library as below
import React from 'react';
import logo from './logo.svg';
import './App.css';
import {Button} from "@my-project/library";function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a className="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer" >
Learn React
</a>
<Button>Library Button</Button>
</header>
</div>
);
}
Note — Above code is the default code given by CRA. We just imported button from the library package and used that in JSX to check it is working or not. After importing, screen renders as below.
Oops, Something went wrong?
Issue — react and react-dom is using different instances of react. Why?
webapp while running uses react-dom and react from its own node_modules folder (i.e webapp/node_modules) for rendering DOM, but when it comes to rendering the 3rd party package (i.e. library package in our case) which uses another 3rd party package (i.e. material UI in our case) the used react version is from library/node_modules which make the instance conflict as React library gives one of the reason for this error — “You might have more than one copy of React in the same app.” Read more at — https://reactjs.org/warnings/invalid-hook-call-warning.html.
There are multiple ways to use the single instance (or copy) of react throughout the monorepo-
- yarn workspaces
- lerna hoisting way
but we will see another way in this blog which is overriding the CRA webpack configuration to point all the react import statements to the same copy.
There are different 3rd party libraries which allows us to override the configuration like -
4.4)Let’s use craco. For this, we need to install it, create a craco.config.js and update the react scripts to run via craco
lerna add @craco/craco packages/webapp
create file webapp/craco.config.js with below code
const path = require('path');const resolvePackage = relativePath => path.resolve(__dirname,relativePath);module.exports = {
webpack:{
alias:{
react:resolvePackage('./node_modules/react')
}
}
}
Here we are making the alias of react so that while compiling whenever webpack encounters the react import statement it should point to webapp/node_modules folder
Replace the “start”: “react-scripts start” script to “start”: “craco start” in webapp/package.json and run the start script again which renders as below
Finally, we are able to use the components from other package using lerna way of creating monorepo.
4.5)I would like to focus on one more thing, if you remember in the library/package.json, we have used lib/index.js in the main key but what if we want to use the code from src folder only, although it is not recommended but let's check what happens if we change that and run the webapp again
Change the library/package.json main key entry from “main”: “lib/index.js” to “main”: “src/index.js” and re-run the webapp
Why it got broken? Because in CRA, babel is setup in such a way that it will not transpile the code from the 3rd party library, it only transpile the code written under the CRA application which is under src folder.
Here again, craco will rescue us
It works with craco-babel-loader which overrides the babel-loader configuration, Let see how,
Install craco-babel-loader npm package
lerna add craco-babel-loader packages/webapp
Add below code in craco.config.js
const resolvePackage = relativePath => path.resolve(__dirname,relativePath);module.exports = {
plugins:[{
plugin:cracoBabelLoader,
options:{
includes:[resolvePackage('../library')],
excludes:[/node_modules/]
}
}]
}
Here we have override the babel loader configuration so that while transpiling the code to ES5 by babel, it includes library package as well and exclude the node_modules folder
You can get the complete code here https://github.com/harshmons/monorepo-with-lerna-and-npm. Just follow the Readme to run the code.
In my next upcoming blogs, I will show how “hoisting way of lerna or yarn workspaces” helps us to manage the dependencies in a better way.