Learn how to scaffold a Monorepo to manage multiple Create React App’s that share a common component library using Lerna and Yarn Workspaces. We will setup Storybook and Jest to work well in the Monorepo environment.

🚨 Update — 2019/07/17

Please use React Workspaces Playground instead of this guide. React Workspaces Playground is a supported project that allows you to test-drive everything in this guide with the latest version of React and Storybook.

This guide demonstrates how to achieve similar effects via monkey-patching - a bad practice that is difficult to maintain. Please review my React Workspaces slides for more information.

Featuring:

Note: Download the code for this guide from GitHub f1lt3r/monorepo-react.

⚠️  Important Steps

If you are checking out this code to test without using this guide, please remember to follow these important steps in order.

  1. npm install in Lerna root directory
  2. npm install in the packages/my-react-appdirectory
  3. npx babel-loader-lerna-cra in the Lerna root directory

The Case For Monorepos

Imagine a scenario where you are building a suite of three React apps that share the same architecture, design patterns, components and styles. Now imaging making an update to a low-level component like a Button that is used in all three apps, as well as one sub-component.

In this scenario, you would be forced into a process like this:

  1. Update the Button code in the Button’s git respository. (Component #B in the diagram above)
    1. Create a Pull Requst in the Component #Brepo and get the new code into master.
    2. Publish the Component #BButton code on a public or private NPM service.
  2. Go into React Component #Crepo that uses the Button and update the package.jsondependancies.
    1. Create a second Pull Request in the Component #C, repo and get that new code into master.
    2. Publish the component to the NPM repo.
  3. Go into React App #1
    1. Update the dependencies.
    2. Republish the package on npm service.
    3. Submit a new PR.
    4. Deploy
  4. Go into act App #2
    1. Update the dependencies.
    2. Republish the package on npm service.
    3. Submit a new PR.
    4. Deploy
  5. Go into React App #3
    1. Update the dependencies.
    2. Republish the package on npm service.
    3. Submit a new PR.
    4. Deploy

That is five pull requests for a change to one button component!

Clearly this is less than ideal.

A Simpler Solution

Now imagine using a single repo for the same update. If we use a Monorepo tool like Lerna, the update process will look more like this:

  1. Update the Button code in the Button’s git directory. (Component #B in the diagram above)
  2. Run lerna bootstrapto crosslink the Button Component #Binto all the sub dependancies.
  3. Run lerna publishto update the packages in your privite NPM service.
  4. Create a Pull Requst in the Monoreporepo and get the new code into master.
  5. Re-deploy the apps with the updated package.jsonversion numbers.

Now everything is done in one Pull Request.

This is why large organizations like Facebook and Google make good use of Monorepos. This process can be simplified to use a single shared repo for all the depenencies and apps. The Monorepo scales up without losing as much engineering velocity and reduces human error lost from switching contexual focus.

The following guide will show you how to set up a such Monorepo for a React project.

Prerequisites

npm i -g lerna
npm i -g create-react-app

Create a directory for your Monorepo project.

cd ~/repos
mkdir monorepo-react
cd monorepo-react

Setup Lerna

Note: In order restart these this guide at any time, you remove the following files and directories:

👇

sudo rm -r node_modules packages stories .storybook coverage stories
rm package.json package-lock.json setupTests.js lerna.json

Create and initialize your Lerna monorepo:

lerna init

Your package.jsonshould now look like this:

{
  "name": "root",
  "private": true,
  "devDependencies": {
    "lerna": "^3.4.3"
  }
}

Install Common Dependencies

Installing these common dependencies will allow you to:

  • Run Storybook for the root of your project.
  • To have Storybook auto-install the right modules for your React project.
  • Have Babel transpile correctly for code, testing and Storybook.
npm i -D react react-dom @babel/core@^7.0.0-0 @babel/cli babel-plugin-transform-es2015-modules-commonjs babel-jest enzyme enzyme-adapter-react-16 jest react-test-renderer [email protected] @babel/preset-env @babel/preset-react

Your package.jsonshould now look like this:

{
  "name": "root",
  "private": true,
  "devDependencies": {
    "@babel/cli": "^7.1.2",
    "@babel/core": "^7.1.2",
    "@babel/preset-env": "^7.1.0",
    "@babel/preset-react": "^7.0.0",
    "babel-core": "^7.0.0-bridge.0",
    "babel-jest": "^23.6.0",
    "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2",
    "enzyme": "^3.7.0",
    "enzyme-adapter-react-16": "^1.6.0",
    "jest": "^23.6.0",
    "lerna": "^3.4.3",
    "react": "^16.6.0",
    "react-dom": "^16.6.0",
    "react-test-renderer": "^16.6.0"
  }
}

Install Storybook React

Now we will install and initialize Storybook version 4.

npx -p @storybook/cli@alpha sb init

Note: Installing the @alphaversion (currently @4.0.0-rc.6), will allow us to set our Babel configuration inside of our package.jsonfiles which will make configuration easier for sub-packages.

Your root package.jsonfile should now look like this:

{
  "name": "root",
  "private": true,
  "devDependencies": {
    "@babel/cli": "^7.1.2",
    "@babel/core": "^7.1.2",
    "@babel/preset-env": "^7.1.0",
    "@babel/preset-react": "^7.0.0",
    "babel-core": "^7.0.0-bridge.0",
    "babel-jest": "^23.6.0",
    "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2",
    "enzyme": "^3.7.0",
    "enzyme-adapter-react-16": "^1.6.0",
    "jest": "^23.6.0",
    "lerna": "^3.4.3",
    "react": "^16.6.0",
    "react-dom": "^16.6.0",
    "react-test-renderer": "^16.6.0",
    "@storybook/react": "^4.0.0-alpha.25",
    "@storybook/addon-actions": "^4.0.0-alpha.25",
    "@storybook/addon-links": "^4.0.0-alpha.25",
    "@storybook/addons": "^4.0.0-alpha.25",
    "babel-loader": "^8.0.4"
  },
  "dependencies": {},
  "scripts": {
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook"
  }
}

Now you can test that Storybook runs on your machine.

npm run storybook

Storybook should now launch in your web browser automatically.

List the storybook files:

tree -C .storybook stories
  • Your .storybook/directory contains your Storybook configuration.
  • Your stories/directory is where your global Storybook stories live.

Note: To install tree: brew install tree

Create Your React App

Create a home in packages/my-react-appfor your React App.

cd ~/repos/monorepo-react/packages/
create-react-app my-react-app

Run your React app to test things worked.

cd my-react-app
npm run start

You should now see an error message about Webpack like this one:

We will work around this by setting the SKIP_PREFLIGHT_CHECK=truein the .envfile as suggested.

echo "SKIP_PREFLIGHT_CHECK=true" > .env

You should now be able to run your React app, and your browser should launch automatically.

npm run start

Create an External React Component

Lets create our first external React component. We will do this inside our ./packagesdirectory provided by Lerna.

cd ~/repos/monorepo-react/packages/
mkdir comp-button
cd comp-button

Create a packages/comp-button/package.jsonfile like this:

{
  "name": "@project/comp-button",
  "version": "0.1.0",
  "description": "A simple button component",
  "main": "dist/index.js",
  "module": "src/index.js",
  "scripts": {
    "transpile": "babel src -d dist --ignore '**/*.spec.js,**/*.stories.js'",
    "jest": "jest --coverage --verbose --color"
  },
  "babel": {
    "presets": [
      "@babel/preset-env",
      "@babel/preset-react"
    ],
    "env": {
      "test": {
        "plugins": [
          "transform-es2015-modules-commonjs"
        ]
      }
    }
  }
}

What is going on in the package.jsonfile:

  • name: The organizational namespace for your component when installed via NPM or cross-linked Lerna.
  • main: The the compiled code that will be shipped with the build of your React app.
  • module: The pre-compiled code that will be imported as a local run-time dependency while developing the app or running tests.
  • transpile: An NPM script start the transpile of your code with Babel. Note: We are not using buildbecause we want to reserve this word later to build our React apps with lerna run build.
  • babel: This setup configures our component to transpile with Babel 7 for React.

Note: Because we installed components like react, react-dom, @babel/core@^7.0.0-0in our root package.jsonwe do not have to install them again in this package.

Make a source directory for your React component.

mkdir src
cd src

Create your React component in packages/comp-button/index.js:

import React from 'react'

const Button = ({ type = 'button', children, onClick }) => (
    <div>
      <button type={type} className="button" onClick={onClick}>
        {children}
      </button>
  </div>
)

export default Button

Transpile Your Component

Now lets try to transpile your React code to ECMAScript 2015 (JavaScript with support for older browsers).

lerna run transpile

You should see the following output:

Your ./dist/directory should now contain the transpiled index.jsfile:

tree -C ../dist

The ./dist/index.jsfile should contain your transpiled code, like this:

"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.default = void 0;

var _react = _interopRequireDefault(require("react"));

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

var Button = function Button(_ref) {
  var _ref$type = _ref.type,
      type = _ref$type === void 0 ? 'button' : _ref$type,
      children = _ref.children,
      onClick = _ref.onClick;
  return _react.default.createElement("div", null, _react.default.createElement("button", {
    type: type,
    className: "button",
    onClick: onClick
  }, children));
};

var _default = Button;
exports.default = _default;

Test Your Component

While we are here, lets create a Jest spec for your component in packages/comp-button/src/index.spec.js:

import React from 'react';
import {mount} from 'enzyme';
import Button from '.';

describe('Button Component', function() {
  it('renders without props', function() {
    const wrapper = mount(<Button />);
    const button = wrapper.find('.button');
    expect(button.length).toBe(1);
  })

  it('renders without props', function() {
    const wrapper = mount(<Button />);
    const button = wrapper.find('.button');
    expect(button.length).toBe(1);
  })

  it('renders children when passed in', () => {
    const wrapper = mount(
      <Button>
        <p className="child">Some Child</p>
      </Button>
    );

    const child = wrapper.find('.child')
    expect(child.length).toBe(1)
  })

  it('handles onClick events', () => {
    const onClick = jest.fn()
    const wrapper = mount(
      <Button onClick={onClick} />
    )

    wrapper.find('button').simulate('click')

    expect(onClick.mock.calls.length).toBe(1)
  })
})

Note: we installed [email protected]and babel-jestearlier to make Babel 7 code compatible with Jest. (See: Install Common Dependencies)

Add the following “jest” section to your root package.json:

 "jest": {
    "setupFiles": [
      "../../setupTests"
    ]
  }

Your packages/comp-button/package.jsonshould now look like this:

{
    "name": "@my-project/comp-button",
    "version": "0.1.0",
    "description": "A simple button component",
    "main": "dist/index.js",
    "module": "src/index.js",
    "scripts": {
    "transpile": "babel src -d dist --ignore '**/*.spec.js,**/*.stories.js'",
      "jest": "jest --coverage --verbose --color"
    },
    "babel": {
      "presets": [
        "@babel/preset-env",
        "@babel/preset-react"
      ],
      "env": {
        "test": {
          "plugins": [
            "transform-es2015-modules-commonjs"
          ]
        }
      }
    },
    "jest": {
      "setupFiles": [
        "../../setupTests"
      ]
    }
  }

When Jest runs, ../../setupTestsfile will reference setupTests.jsin your Monorepo root.

Let’s add this setupTests.jsfile with some Enzyme helpers:

const enzyme = require('enzyme');
const Adapter = require('enzyme-adapter-react-16');
enzyme.configure({ adapter: new Adapter() });

Note: we deliberately use the older require syntax here, so that setupTests.jsis loadable without additional Babel configuration.

Now lets run Jest to see the spec working:

lerna run jest

Note: We are using jestand not testto reserve the word “test” for running all tests, including End to End, linting, etc.

Add a Story for Your React Component

Now lets create a Storybook story for our new Button component:

Add the following code to index.stories.js:

import React from 'react'
import { storiesOf } from '@storybook/react'
import { action } from '@storybook/addon-actions'

import Button from '.'

storiesOf('Button', module)

  .add('with text', () => (
      <Button onClick={action('clicked')}>Button</Button>
  ))

  .add('with some emoji', () => (
      <Button onClick={action('clicked')}>😀 😎 👍 💯</Button>
  ))

  .add('with a theme provider', () => (
    <Button onClick={action('clicked')}>Button</Button>
  ))

Reconfigure Storybook

We will now need to configure Storybook to load stories from all the packages/**directories, instead of loading stories/from your Monorepo root.

Edit your Storybook configuration in ~/repos/monorepo-react/.storybook/config.js, so it look like this:

import { configure } from '@storybook/react';

// automatically import all files ending in *.stories.js
const req = require.context('../packages', true, /.stories.js$/);
function loadStories() {
  req.keys().forEach(filename => req(filename));
}

configure(loadStories, module);

It’s now safe to delete the stories/directory at the Monorepo root.

cd ~/repos/monorepo-react/
sudo rm -r stories

Lets check that the Storybook still loads with your comp-buttonStory:

npm run storybook

You should now be able to see your button component Story which was built from your packages/comp-buttondirectory:

Add the following dependency to your packages/my-react-app/package.json:

{
  "dependencies": {
    "@my-project/comp-button": "*"
  }
}

Your packages/my-react-app/package.jsonshould now look like this:

{
  "name": "@my-project/my-react-app",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "react": "^16.6.0",
    "react-dom": "^16.6.0",
    "react-scripts": "2.0.5",
    "@my-project/comp-button": "*"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": "react-app"
  },
  "browserslist": [
    ">0.2%",
    "not dead",
    "not ie <= 11",
    "not op_mini all"
  ]
}

We can now crosslink our packages using lerna bootstrap.

lerna bootstrap

You should see the following success message:

Use Your Component in The React App

Add the follow lines to packages/my-react-app/src/App.js:

import CompButton from '@my-project/comp-button';
<CompButton>Foobar!</CompButton>

Your file will now look like this:

import React, { Component } from 'react';
import logo from './logo.svg';
import './App.css';
import CompButton from '@my-project/comp-button';

class App extends Component {
  render() {
    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>
          <CompButton>Foobar!</CompButton>
        </header>
      </div>
    );
  }
}

export default App;

Now start your app:

npm run start

You should see the following error:

The React App is failing to compile because Create-React-App’s Webpack config is unaware of the any external modules. This means Webpack can not tell Babel-Loader about your component directories, and the sources do not get transpiled.

It seems like this will problem may go away with future versions of Create-React-App, although this may require Yarn Workspaces. So make sure you check the GitHub Issue Create-React-App-Lerna-Support to see if this feature os landed before using the following work-around.

Rewire Your React App for Lerna

I created a small Work-around Node Module to override Create-React-App Webpack configs inside Lerna projects, called Babel-Loader-Lerna-CRA. It’s pretty simple. It just updates the Webpack paths for Babel-Loader.

You can install this package using NPM:

npm i -D babel-loader-lerna-cra

Now lets update the package.jsonin our Lerna root with glob patterns that describe the relationship between our components and our app.

"babel-loader-lerna-cra": {
  "imports": "packages/comp-*/src",
  "apps": "packages/*react-app*"
}

Your package.jsonshould now look like this:

{
  "name": "root",
  "private": true,
  "devDependencies": {
    "@babel/cli": "^7.1.2",
    "@babel/core": "^7.1.2",
    "@babel/preset-env": "^7.1.0",
    "@babel/preset-react": "^7.0.0",
    "@storybook/addon-actions": "^4.0.0-alpha.25",
    "@storybook/addon-links": "^4.0.0-alpha.25",
    "@storybook/addons": "^4.0.0-alpha.25",
    "@storybook/react": "^4.0.0-alpha.25",
    "babel-core": "^7.0.0-bridge.0",
    "babel-jest": "^23.6.0",
    "babel-loader": "^8.0.4",
    "babel-loader-lerna-cra": "^0.1.2",
    "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2",
    "enzyme": "^3.7.0",
    "enzyme-adapter-react-16": "^1.6.0",
    "jest": "^23.6.0",
    "lerna": "^3.4.3",
    "react": "^16.6.0",
    "react-dom": "^16.6.0",
    "react-test-renderer": "^16.6.0"
  },
  "dependencies": {},
  "scripts": {
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook"
  },
  "babel-loader-lerna-cra": {
    "imports": "packages/comp-*/src",
    "apps": "packages/*react-app*"
  }
}
  • The importsrefer to components that the React app will neeed to transpile.
  • The appsinform babel-loader-lerna-crawhere the Webpack overrides will need to happen.

Now lets bootstrap the Webpack configs in our React app with babel-loader-lerna-cra:

npx babel-loader-lerna-cra

You should see the following output:

Now lets try running your React App again:

cd ~/repos/monorepo-react/packages/my-react-app
npm run start

You should now see the React App launch in a browser with your CompButtoncomponent rendering with the text “Foorbar!”

So what did we get out of this work-around?

  • Auto Transpilation of Lerna Siblings

    Our React App can now import sibling Lerna depedencies and transpile then when needed.

  • React App Hot Reloading

    When we change our React component file, will hot-update the app without having to add any global watchers to the Lerna project to kick of a transpile.

    Here is our CompButtoncomponent being Hot-Reloaded as it is being updated:

  • Storybook Hot Reloading

    Nothing special here, but it’s worth noting that our Storybook still hot-reloads too.

Conclusion

I think this is as far as I would like to take this in a single article. I hope someone else finds this setup useful. If people express interest, I will follow up with a Part 2 on how to setup CI to ship multiple React Apps from this Monorepo setup.

Comments, feedback, suggestions always welcome!

Always ready to learn.

Interesting Articles on This Topic:

       ___ _ _  ____________
 ___  | __/ | _   ___   _ \
/___/ | _|| | __| | _ \   /
      |_| |_|____|_||___/_|_\