I recently did a switch to monorepo for my React components with Lerna.

I was usually against having a monorepo because

  • large codebase makes unnecessary noise when you need to edit just one independent part of logic.
  • security (e.g. if there's some kind of a breach in one code base, others are still intact)
  • usually looks dirtier (with more files it's harder to view what's important for your scope, and did you hear about version hell?)

For example, does your frontend colleague need to know about your backend?

Now, that being said, monorepo is convenient because everything is in one place, and other repos can share configuration like testing, linting and build configuration.

Why did I switch?

I manage too much of my own react and angular components, and migrations have become a bore for me!

I have to check out each project, run installs and other scripts. Well, same procedure is for every angular/react repository.

How to make it less painful?

I like to analyze popular repositories on Github, and see how they approached certain problems.

In this case, I picked material-ui. They have a lot of packages, and they use Lerna for handling a monorepo.

Getting started

I followed the instructions on Lerna getting started, and created a first package:

npx lerna init
lerna create mui-copy-field 

I moved my old mui-copy-field library from https://github.com/eisberg-labs/mui-copy-field into https://github.com/eisberg-labs/react-components.

Previously lerna also required lerna bootstrap command for linking packages together and installing package dependencies, but that was before workspaces were introduced.

Running Commands

lerna run ... will run a command that is defined in your package's npm configuration file.

E.g. when I run lerna run test, lerna will analyze https://github.com/eisberg-labs/react-components/blob/main/packages/mui-copy-field/package.json and run test command from that configuration. That goes for each package.

When you want to run a command for a single package, it should go similar to this:

lerna run --scope @eisberg-labs/mui-copy-field test

Scope flag broadens the number of packages for command, in this case a single package.

Configuration files

I like to keep as much possible configuration at the root of the repo, and then from package I'd call e.g.

jest -c ../../jest.config.js

Tips for tsconfig.json


Define compilerOptions at rootlevel, and specific conf like excludes at the package level:

{
  "extends": "../../tsconfig.json",
  "include": [
    "src"
  ],
  "exclude": [
    "__tests__",
    "node_modules",
    "dist"
  ]
}

Testing

I use my usual jest.config.js, and like to extract the test code from the source code, that's why I have 2 tsconfig.jsons. Tests only need to be compiled when running tests. And they are both defined at the package level, but extend from root tsconfig.json (compiler options are the same).

jest.config.js is at the root level. When lerna calls jest, it will run relative to the package folder.

module.exports = {
  clearMocks: true,
  collectCoverage: true,
  coverageDirectory: "coverage",
  coveragePathIgnorePatterns: [
    "/node_modules/",
    "<rootDir>/__tests__/"
  ],
  transform: {
    '^.+\\.tsx?$': [
      'ts-jest',
      {tsconfig: './tsconfig.test.json'},
    ],
  },
  watchman: false,
  preset: 'ts-jest'
};

Since I like to write my tests in typescript, a transform with ts-jest is needed tsconfig.test.json is defined at the package level.

Building packages with Rollup

This one was a little bit trickier for me. I use rollup, as I saw most of the react libraries use it. One alternative for you might be webpack. But rollup seems easier to configure.

At first I didn't realize that most examples on the internet refer to the 2.x rollup version, and I installed the 3.x version at first. Now I use rollup ^2.79.1.

I publish the package in 2 formats, cjs (commonjs) and es (es modules).

First prerequisite for building is transpiling the typescript. I could use tsc or babel. Tsc transpiles, but does not polyfill, but it's perfectly good for typechecking and declaration. Babel polyfills and offers more custom transformations.

My babel.config.json is at the root level.

{
  "presets": [
    "@babel/preset-typescript",
    "@babel/preset-env",
    "@babel/preset-react"
  ],
  "plugins": [
    "@babel/plugin-transform-runtime"
  ]
}

And finally my rollup.config.js is at the root level, and looks:

import excludeDependenciesFromBundle from "rollup-plugin-exclude-dependencies-from-bundle";
import {nodeResolve} from '@rollup/plugin-node-resolve';
import typescript from '@rollup/plugin-typescript';
import terser  from '@rollup/plugin-terser';
import commonjs from '@rollup/plugin-commonjs';
import babel from '@rollup/plugin-babel';
import * as fs from "fs";
import path from "path";

const PACKAGE_NAME = process.cwd();
const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_NAME, 'package.json'), 'utf-8'));

const commonjsOptions = {
  ignoreGlobal: true,
  include: /node_modules/,
}
const extensions = ['.js', '.ts', '.tsx'];

const babelOptions = {
  exclude: /node_modules/,
  extensions,
  configFile: '../../babel.config.json',
  babelHelpers: 'runtime'
};
const nodeOptions = {
  extensions,
};
const typescriptOptions = {
  tsconfig: `${PACKAGE_NAME}/tsconfig.json`,
  declaration: true,
  declarationDir: '.',
  emitDeclarationOnly: true,
  declarationMap: true,
};

export default {
  input: `${PACKAGE_NAME}/src/index.ts`,
  external: [...Object.keys(pkg.peerDependencies), '@emotion/cache'],
  output: [
    {
      file: pkg.main,
      format: 'cjs'
    },
    {
      file: pkg.module,
      format: 'es'
    }
  ],
  plugins: [
    nodeResolve(nodeOptions),
    typescript(typescriptOptions),
    excludeDependenciesFromBundle({peerDependencies: true}),
    babel(babelOptions),
    commonjs(commonjsOptions),
    terser(),
  ]
}

I need PACKAGE_NAME because I load npm configuration for each package instead of repeating myself like defining external (peer dependencies should be marked as external, omitted from build).

But that maybe my misunderstanding, because I thought none of the mui components would be included in my build, but they were. That's why I added rollup-plugin-exclude-dependencies-from-bundle to exclude unnecessary repetition since peer dependencies should be or already are installed by the user.

Let's explain transformations:

  • nodeResolve locates modules using the node resolution, and it should only resolve js|ts|tsx.
  • typescript does type checks and generates declaration in the dist directory.
  • excludeDependenciesFromBundle({peerDependencies: true}) - will exclude peer dependencies from the bundle.
  • babel transpiles typescript code.
  • commonjs converts commonjs modules to es6, so they can be included in a rollup bundle.
  • terser generates minified output bundle.

Publishing a package

lerna run build
lerna version --no-private
lerna publish from-git --no-private
lerna publish from-package --registry https://npm.pkg.github.com

Build has a build prehook that runs tests and linting, and then rollup.

Lerna version will bump a new version, create a new git tag and push.

Lerna publish has a from-git flag, needed if followed right after lerna version.

I like to publish my packages both to the github registry and npm, I mentioned why in this post.