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 thisbuild 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 | ├── packages |
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 | { |
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 | { |
Now, when any of your colleagues opens this project and is missing required plugins, he will get a nice popup with recommended extensions.
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 | { |
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 | { |
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 | { |
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 | "lint": "eslint packages --ext ts,tsx", |
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 | "references": [ |
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 | { |
Configure project references in tsconfig.project.json
:
1 | { |
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 | ├── package-1 |
Each package should contain a package.json
with at least the following keys:
1 | { |
Package should also contain a tsconfig.json
with the following config:
1 | { |
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 | { |
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 filesbuild
compiles packages to esm and commonjs formatbuild:cjs
compiles files to commonjs formatbuild:esm
compiles files to esm formatdev:cjs
starts watching packages and compiles to cjs on changedev:esm
starts watching packages and compiles to esm on changemodule: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/cjsmodule:esm
is a reverse script version ofmodule: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 | import React from "react" |
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 | { |
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 | { |
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.