Setup monorepo for React components using TypeScript

Some time ago I’ve started working on React based UI library. I wanted to keep my components together but publish them to npm separately. I’ve decided to use monorepo.

Monorepo is a development strategy where code for many projects is stored in the same repository.

Advantages of monorepo:

  • allows to share dependencies - each package can link to dependency stored in project root
  • one project configuration for all packages - linter, editor settings, etc
  • easier to refactor
  • commit changes for multiple packages simultaneously - changes in one package breaks other? No problem, fix other package and commit it together with breaking changes.

Monorepo helps with keeping components in one place but there are other challenges:

  • sharing dependencies across packages - you need a tool for this
  • build system - build each package separately or all at once?
  • publishing packages to NPM
  • configuring one linter for all packages
  • development playground - you need to see what you build, right?

You can see configured repository here. Want to know details? Read on.

Repository structure

1
2
3
4
5
6
7
8
9
10
11
├── packages
│ ├── package-1
│ │ ├── src
│ │ │ ├── index.tsx
│ │ ├── package.json
│ │ ├── tsconfig.json # Package specific configuration
│ ├── package-2
│ ├── package-3
├── package.json
├── tsconfig.json # TypeScript build configuration
├── tsconfig.project.json # TypeScript references configuration

Sharing dependencies between components

Each package can have custom dependencies. To install and hoist all dependencies for all packages at once you need Yarn or lerna. In this article we will stick with Yarn since it’s more common than Lerna.

Hoisting is a process of lifting an entity, in this case a dependency from package node_modules to root node_modules.

Getting Yarn to work in monorepo is easy, you need to add one line to package.json:

1
"workspaces": ["packages/*"]

This will tell Yarn to install all dependencies in packages/* and hoist them to root node_modules. To learn more, you can read docs about Yarn workspaces.

To install dependencies in all packages, run yarn in project root or in any of the packages.

Configuring ESLint and Prettier

You can view this pull request to see how to configure ESLint and prettier or just keep on reading.

ESLint is a tool to check your code for common mistakes. Prettier is a tool that can format your code to make it more readable. Those two tools can coexist and if you configure them properly, they can save you a lot time spent on debugging and formating.

We will use create react app ESLint configuration, to install it, execute this in project root:

1
yarn add -W eslint-config-react-app babel-eslint@10.x eslint@5.x eslint-plugin-flowtype@2.x eslint-plugin-import@2.x eslint-plugin-jsx-a11y@6.x eslint-plugin-react@7.x eslint-plugin-react-hooks@1.5.0 @typescript-eslint/eslint-plugin@1.5.0 @typescript-eslint/parser@1.5.0

After that, add eslint configuration. You can create .eslintrc.json file or add eslintConfig key to package.json, with the following settings:

1
2
3
4
{
"extends": ["react-app", "plugin:jsx-a11y/recommended"],
"plugins": ["jsx-a11y"]
}

At this point you can run npx eslint packages --ext ts,tsx to lint your packages.

Adding ESLint integration for VS Code

Validation in terminal? We can do better than that. All we need to do is to install eslint extension and select languages to validate.

If you work in a team, it’s good idea to let other know that they also need eslint extension. To do that, we can use .vscode/extensions.json file with the following content:

1
2
3
{
"recommendations": ["dbaeumer.vscode-eslint"]
}

Now, when any of your colleagues opens this project and is missing required plugins, he will get a nice popup with recommended extensions.
install recommended extension

Last step is to add eslint configuration. We can do that in .vscode/settings.json file. Create it and fill with the following settings:

1
2
3
4
5
6
7
8
{
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
]
}

At this point you should have a nice validation inside editor. To check if it works, you can add this code inside any of tsx files <img src="icon.png " />. ESLint should let you know that you’re missing an alt attribute.

Adding Prettier integration for VS Code

To get your code auto formatted, we will use a Prettier extension for VS Code. We could also add a prettier to project dependencies but if your team is using only VS Code then it’s fine to skip it.

To get started, add prettier to recommended extensions in .vscode/extensions.json:

1
2
3
{
"recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
}

After that, you will get a popup inside VS Code with info about recommended prettier extension. To finish configuration, you need to enable auto formatting and eslint integration. ESLint integration allows prettier to use eslint config for formatting.

Add these settings to the .vscode/settings.json:

1
2
3
4
{
"editor.formatOnSave": true,
"prettier.eslintIntegration": true,
}

Adding linting commands to npm scripts

If you want to use linter in continuous integration tool, you can add lint commands to your packages.json scripts section:

1
2
"lint": "eslint packages --ext ts,tsx",
"lint:fix": "yarn lint --fix"

Build system

This one was really tricky, at first I’ve used microbundle tool. It worked quite ok for small amount of packages. It had one big issue: 2-3 node processes were created for each package build. It killed my CPU when number of packages grew.

My next try was webpack. I got it to work but only partially. At this point I had a lot of dependencies used by build system. I started to look for a better solution.

I’ve found a solution in a TypeScript. Since version 3.0, TypeScript support project references.

Project references are like:

  • TS Compiler: I’m starting compilation.
  • Main package: Okey, you can compile me but I require these referenced packages, so please go and compile them first.
  • TS Compiler: Okey, I’m going into button package. I’m starting compilation.
  • Button package: Hey, you can compile me but I require icon package to be compiled first.
  • TS Compiler: Okey, I’m going into icon package. I’m starting compilation.
  • Icon package: Yeah, it’s bundle time!

At this point, compiler goes back to button, compiles, goes back to main package and finish process. In the result icon package was compiled first, then button and then the main package.

Project references is an array of objects, each object contains a path to a directory with tsconfig.json:

1
2
3
4
5
"references": [
{ "path": "packages/alert" },
{ "path": "packages/button" },
{ "path": "packages/icon" }
]

Visit TypeScript Project References docs to learn more.

Setting up build system

First, add TypeScript in root directory:

1
yarn add typescript@3.3.4000 -W

We’re using version 3.3.4000 because newer versions are much more slower at the time of writing this article.

Next, add a build configuration tsconfig.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,

"jsx": "react",
"target": "es5",
"module": "commonjs",
"moduleResolution": "node",

"strict": true,
"noImplicitAny": false,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
}
}

Configure project references in tsconfig.project.json:

1
2
3
4
5
6
7
8
{
"files": [],
"references": [
{ "path": "packages/alert" },
{ "path": "packages/button" },
{ "path": "packages/icon" }
]
}

files is set to empty array because this tsconfig should not compile any files. It’s used only as list of packages that compiler will go into and compile. Every package you want to compile should be listed in this file and should contain custom tsconfig.json in.

Configuring packages

1
2
3
4
5
├── package-1
│ ├── src
│ │ ├── index.tsx
│ ├── package.json
│ ├── tsconfig.json

Each package should contain a package.json with at least the following keys:

1
2
3
4
5
6
7
{
"name": "@PACKAGE_SCOPE/PACKAGE_NAME",
"version": "0.1.0",
"main": "lib/cjs/index.js",
"typings": "lib/cjs/index.d.ts",
"module": "lib/esm/index.js"
}

Package should also contain a tsconfig.json with the following config:

1
2
3
4
5
6
7
8
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "lib/esm",
"rootDir": "src"
},
"references": [{"path": "../button"}, {"path": "../icon"}]
}

This config extends build configuration from root directory. It also configures input and output directory. At the end you have, a references key, it’s array of dependencies that should be build before compiler can start compiling this package. Leave empty array if there are no dependencies.

The last requirement is to have index.tsx file inside src directory.

Build scripts

The last part of build system are scripts inside package.json:

1
2
3
4
5
6
7
8
9
10
11
12
{
"scripts": {
"prebuild": "rm -rf ./packages/*/lib",
"build": "yarn build:cjs && yarn build:esm",
"build:cjs": "yarn module:cjs && tsc --build tsconfig.project.json",
"build:esm": "yarn module:esm && tsc --build tsconfig.project.json",
"dev:cjs": "yarn build:cjs --watch",
"dev:esm": "yarn build:esm --watch",
"module:cjs": "sed -i -e '0,/target/{s:esnext:es5:}' -e '0,/module/{s:esnext:commonjs:}' ./tsconfig.json && sed -i -e s:lib/esm:lib/cjs: packages/**/tsconfig.json",
"module:esm": "sed -i -e s:es5:esnext: -e s:commonjs:esnext: ./tsconfig.json && sed -i -e s:lib/cjs:lib/esm: packages/**/tsconfig.json",
}
}

Quite a lot is going on here so let’s go line by line.

  • prebuild is a script that is always called before build, it removes all compiled files
  • build compiles packages to esm and commonjs format
  • build:cjs compiles files to commonjs format
  • build:esm compiles files to esm format
  • dev:cjs starts watching packages and compiles to cjs on change
  • dev:esm starts watching packages and compiles to esm on change
  • module:cjs in tsconfig.json replaces words esnext to es5 for target key, and esnext to commonjs for module key. It also replaces out directory in each package from lib/esm to lib/cjs
  • module:esm is a reverse script version of module:cjs

This setup have one disadvantage, you can’t run dev:cjs and dev:esm simultaneously.

That concludes our build system. It’s not overly complicated(except module:esm/cjs commands) and gets job done really fast(clear build is around 4s for 3 packages). It compiles to commonjs and esnext modules. Typings are also compiled although not bundled.

Add development playground - Storybook

To add Storybook, execute this command in project root:

1
npx -p @storybook/cli sb init

It will install all dependencies and add storybook, build-storybook commands to package.json.

To start Storybook, run yarn storybook and yarn dev:esm to make it reload on change.

Here is a sample story that uses one of our packages:

1
2
3
4
5
import React from "react"
import {storiesOf} from "@storybook/react"
import {Icon} from "@scope/icon"

storiesOf("Icon", module).add("default", () => <Icon />)

Want to deploy Storybook for each pull request in repository? Learn how to do it using CircleCI and GitHub Deployments. You can also check this pull request solution.

Publishing to NPM

Ready to publish your package on NPM? Yeah, you can go into each package directory and run yarn publish but there is a better way. You can use Lerna - it’s a tool that helps with monorepo management. It also allows you to publish multiple packages at once.

Here you can see a pull request example how to add lerna to project.

To get started, add Lerna to project:

1
yarn add lerna -W

Then add lerna.json file:

1
2
3
4
5
{
"npmClient": "yarn",
"useWorkspaces": true,
"version": "independent"
}

Lerna have custom hoisting system but since we use Yarn, we can leverage it’s workspaces power. Last step is too add two scripts to package.json:

1
2
3
4
5
6
{
"scripts": {
"prerelease": "yarn build",
"release": "lerna publish"
}
}

Now you can run yarn release to build packages and release changed packages on NPM.

Want to publish a test version? Use yarn release --canary to release a canary version of each changed package.

Conclusion

  • You can get whole build system using only TypeScript. Awesome!
  • Lerna is useful for publishing packages
  • Yarn is able to manage dependencies of multiple packages
  • ESLint and Prettier are meant to be used together - less time spent on formatting and debugging
  • Storybook is easy to setup playground, it also can be attached to pull request so your team members co do quality assurance tests

Repository with build system can be found here. Check pull requests for easy to add ESLint/Prettier, Lerna and Storybook configurations.