tl;dr
if you just want to cut to the chase, the final product can be seen, cloned and forked right here on GitHub:
https://github.com/LukasBombach/tree-shakable-component-library
At the beginning of this year I got hired by a new company to help with a new (but not public yet) project. For this, we want to get into design systems and component libraries.
The topic itself isn't new to us, but implementing one ourselves is. I got the task at finding a setup that
- lets us work in a monorepo where our ui library is one package and our app(s) is another
- the ui library must be tree-shakable because we have a keen eye on performance
root
∟ packages
∟ app
∟ ui-library
Let me elaborate the second point a but more as this is quite important. If you were to create a component library and bundle everything straight forward you would likely end up creating a single file in the CommonJS (CJS) format.
CommonJS and ES Modules
Today we have several file formats for JS files in the wild and most of them are still actively used. You can read about the different formats in this very good article by @iggredible
https://dev.to/iggredible/what-the-heck-are-cjs-amd-umd-and-esm-ikm
The non-deep-dive version is that there is a commonly used format, coincidently named CommonJS (or CJS) and there is a new_ish_ format that most will be familiar with, ES Modules (ESM).
CJS is what Node.js traditionally used. ESM is newer and standardized (CJS isn't) and will be probably be Node.js' format in the future. It can natively be used since Node.js 12 and is currently flagged as experimental.
Anyhow, using Webpack/Babel or TypeScript you will all be familiar with this format. It is the format that lets you write
import X from "y";
export Z;
👆 ESM
instead of CJS 👇
const X = require("y")
module.exports = Z;
So why is this important at all?
Because of tree-shaking!
The Problem
If you bundle your ui library in a single CJS file that contains, let's say
- a headline
- a button
- a card and
- an image
and you would import only a single component from your library into your app your whole library would get loaded and bundled. That means even if you only use your button in your app, the entirety of your ui library including the headline, the card and the image would end up in your bundle and make your app sooooo much bigger. Loading time, parsing and execution time would possibly blow up.
The solution
...is of course tree-shaking. ES Modules make it possible for bundlers to tree-shake your code. If I am not mistaken, this is because the ESM syntax allows bundlers to statically check which parts of your code are used and which are not, which is harder with require
because it can be used in more dynamic ways, like this
var my_lib;
if (Math.random()) {
my_lib = require('foo');
} else {
my_lib = require('bar');
}
if (Math.random()) {
exports.baz = "🤯";
}
Summary
So in short, if you want to create a component library, you should make it tree-shakable and if you want to do that, you must use ESM.
There are other approaches to this. Material UI and Ant Design go in a different direction.
Instead of creating a single bundle, that exports all components, they actually create a gazillion tiny bundles, one for each component. So instead of
import { Button } from '@material-ui';
You will do this
import Button from '@material-ui/core/Button';
Notice that you load the button from a file (a small bundle) from inside the package /core/Button
.
This does work but requires a particular bundling setup and if you're not careful there is a big risk you bundle duplicate code over and over again for each component.
Now some may have experience with MaterialUI and Ant Design and have noticed that you can do this
import { DatePicker, message } from 'antd';
and everything seems to work alright, but this is just a trick. Ant requires you to install babel-plugin-import and use a bonkers setup with create-react-app
that requires you to rewire your react-scripts
. What this babel plugin does is automatically translate this
import { DatePicker, message } from 'antd';
into this
import { Button } from 'antd';
ReactDOM.render(<Button>xxxx</Button>);
↓ ↓ ↓ ↓ ↓ ↓
var _button = require('antd/lib/button');
ReactDOM.render(<_button>xxxx</_button>);
😧
The bottom line still is:
None of this is necessary with tree-shaking.
The How
In the end, a setup for this can be simple. For the library I will be using
Rollup
TypeScript
and to create a complete setup I will be adding
-
StoryBook
for developing components - a
Next.js
app that consumes the library
I will put everything in a monorepo. This will help us structure the code and we will have a single project, which is split into separate non-monolithic packages while with hot-module-reload and no manual steps while developing.
tl;dr
if you just want to cut to the chase, the final product can be seen, cloned and forked right here on GitHub:
https://github.com/LukasBombach/tree-shakable-component-library
So to begin we have to create a monorepo. I won't explain every line of the code, feel free to ask me in the comments, I will happily try and answer. Also, I will write this using *nix commands as I am using a mac.
So to create a monorepo I'll be using yarn workspaces with 2 packages, app
and ui-library
:
mkdir myproject
cd myproject
yarn init -y
mkdir -p packages/app
mkdir -p packages/ui-library
You now should have a folder structure like this
root
∟ package.json
∟ packages
∟ app
∟ ui-library
Open your project in a code editor and edit your package.json
.
Remove the main
field and add private: true
and workspaces: ["packages/*"]
so it looks like this:
{
"name": "myproject",
"version": "1.0.0",
"license": "MIT",
"private": true,
"workspaces": [
"packages/*"
]
}
You now have a Yarn Workspaces MonoRepo
with the packages app
and ui-library
. cd
into packages/ui-library
, create a package and add the following dependencies:
cd packages/ui-library
yarn init -y
yarn add -DE \
@rollup/plugin-commonjs \
@rollup/plugin-node-resolve \
@types/react \
react \
react-dom \
rollup \
rollup-plugin-typescript2 \
typescript
Now open the package.json
inside packages/ui-library
remove the field for main
and add the following fields for , scripts
, main
, module
, types
, peerDependencies
so you package.json
looks like this:
{
"name": "ui-library",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"build": "rollup -c rollup.config.ts"
},
"main": "lib/index.cjs.js",
"module": "lib/index.esm.js",
"types": "lib/types",
"devDependencies": {
"@rollup/plugin-commonjs": "11.0.2",
"@rollup/plugin-node-resolve": "7.1.1",
"@types/react": "16.9.19",
"react": "16.12.0",
"react-dom": "16.12.0",
"rollup": "1.31.0",
"rollup-plugin-typescript2": "0.25.3",
"typescript": "3.7.5"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
}
in your ui-library
folder add a rollup.config.ts
and a tsconfig.json
touch rollup.config.ts
touch tsconfig.json
rollup.config.ts
import commonjs from "@rollup/plugin-commonjs";
import resolve from "@rollup/plugin-node-resolve";
import typescript from "rollup-plugin-typescript2";
import pkg from "./package.json";
export default {
input: "components/index.ts",
output: [
{
file: pkg.main,
format: "cjs",
},
{
file: pkg.module,
format: "es",
},
],
external: ["react"],
plugins: [
resolve(),
commonjs(),
typescript({
useTsconfigDeclarationDir: true,
}),
],
};
tsconfig.json
{
"compilerOptions": {
"declaration": true,
"declarationDir": "lib/types",
"esModuleInterop": true,
"moduleResolution": "Node",
"jsx": "react",
"resolveJsonModule": true,
"strict": true,
"target": "ESNext"
},
"include": ["components/**/*"],
"exclude": ["components/**/*.stories.tsx"]
}
Now here's the part where I will do some explaining, because this really is the heart of it. The rollup config is set up so that it will load and transpile all TypeScript files using the rollup-plugin-typescript2
plugin. As of today, this one is still more suitable than the official @rollup/plugin-typescript
because the latter cannot emit TypeScript definition files. Which would mean that our UI Library would not export any types to consumers (boo!). We passed an option to the typescript
plugin called useTsconfigDeclarationDir
. This one tells the plugin to use the declarationDir
option from the tsconfig.json
. All other TypeScript options that we have set will already be read from the tsconfig.json
. This means we run TypeScript through Rollup, but all TypeScript related settings reside in the tsconfig.json
.
What is left to do for rollup is to bundle our files. we could apply anything else a bundler does, like minifying, here too. For now we just create an ES Module, but this setup lets you build on it. Now how do we create an ES Module? For this we have these 2 ouput settings:
{
output: [
{
file: pkg.main,
format: "cjs",
},
{
file: pkg.module,
format: "es",
},
],
}
This tells rollup to actually create 2 bundles, one in the CJS format, one in ESM. We take the file names for these from the package.json
, this way they are always in sync.
Ok, but why the CJS option? I'm glad I pretended you asked. When you consume your library, Node.js and other bundlers will not recognize (i.e. pretend it's not even there) if there is no valid main
entry in your package.json
and that entry must be in the CJS format. Also, this will give you backwards compatibility, but without tree-shaking capabilities.
The interesting part is the entry for es
. We get the files name from the module
entry of our package.json
. Bundlers like Webpack and Rollup will recognize that entry and when set up properly use it and expect an ES Module behind it (while ignoring the main
entry).
And...
That's it!
Ok well, we do want to test this out. So let's give it a spin:
In your terminal you should still be in the ui-library
folder. You can confirm that by entering pwd
, which will show you your current working directory.
If you're there enter
mkdir -p components/Button
touch components/index.ts
touch components/Button/Button.tsx
That should have created the files
-
packages/ui-library/components/Button/Button.tsx
and packages/ui-library/components/index.ts
in your project. Edit them as follows
index.ts
export { default as Button } from "./Button/Button";
Button.tsx
import React from "react";
export default () => <button>I SHOULD BE HERE</button>;
🎉 🎉 🎉 Now you can run 🎉 🎉 🎉
yarn build
There is a new folder now called lib
. In that you have 1 folder and 2 files. open index.esm.js
. You should see an ES Module formatted build of your library:
import React from 'react';
var Button = () => React.createElement("button", null, "I SHOULD BE HERE");
export { Button };
🎉 🎉 🎉
Consuming it
Ok, now we can finally harvest the fruits of our labor. We will create a Next.js app in our monorepo and use our typed, tree-shook library.
So, from your ui-library
folder cd
into your app
folder and create a next app:
cd ../app
yarn init -y
yarn add -E next react react-dom
yarn add -DE @types/node typescript
mkdir pages
touch pages/index.tsx
Add the Next scripts
to your package.json
just like you know it from Next:
{
"name": "app",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "9.2.1",
"react": "16.12.0",
"react-dom": "16.12.0"
},
"devDependencies": {
"@types/node": "13.7.0",
"typescript": "3.7.5"
}
}
```
And implement your {% raw %}`pages/index.tsx` like so
**index.tsx**
```tsx
import { Button } from "ui-library";
function HomePage() {
return (
<div>
Welcome to Next.js! Check out my <Button />
</div>
);
}
export default HomePage;
```
Now all that is left to do is start your project and see if your button is there:
```bash
yarn dev
```
You should see this now:
![Next App with Component visible on the screen](https://dev-to-uploads.s3.amazonaws.com/i/szaf3c8b6kze7qcm910r.png)
Ok, that was a long ride for a small visible thing. But now you *do* have a lot:
* You have a monorepo with separate independent packages for your ui library and your app(s)
* Your app can be implemented with any JS based technology
* You can have multiple apps in your monorepo comsuming your component library
* Your UI library is tree-shakable and typed with TypeScript
* You can build on your build setup and apply anything from the Rollup cosmos to it
## Bonus
**Hot-Module-Reloading works!** If you in parallel do
```bash
cd packages/app
yarn dev
```
and
```bash
cd packages/ui-library
yarn build -w
```
you can edit your components in your library, they will be watched and rebundled, your Next app will recognize these changes in your monorepo and update automatically too!
If you want to save some time, I have set up a demo project at
https://github.com/LukasBombach/tree-shakable-component-library/
in which I have also added **StoryBook**. In the readme of that project I have also added some instruction in which you can see the tree-shaking for yourself to make sure it works.
Happy coding ✌️
Top comments (16)
Thanks for the post, Lukas.
Wouldn't ESM have the same problem because of dynamic import()?
I am not sure if tree-shaking doesn't work if dynamic import is used.
My original source for this is this
exploringjs.com/es6/ch_modules.htm...
which is linked here
webpack.js.org/guides/tree-shaking/
If I try to understand it, I would guess that there is a difference when you do a static import like this
import x from "y";
and when you do a dynamic import like this
import(something)
This might seem like nothing, but I guess that when you do static code analysis and all you have is a string, you can see 100% by the syntax if it is a static import or a dynamic one. And the static one can be tree-shaken and I guess the dynamic one can't.
That's my guess at least.
You are right.
I dug around a bit, and the Webpack author commented that with dynamic import, tree shaking is now performed.
github.com/webpack/webpack.js.org/...
Hai Lukas,
Thank you for writeup,
i was using cra-bundle-analyser NPM package and trying to analyse build file, look like their is no tree shaking, it is generating one file index.cjs.js and importing it, size of the imported file is same despite of one component import or N, it is importing complete file,
can you please tell me how did you verify tree-shaking
I have been trying to build a react component library that exports ESM. I tried your config and it does work when I run the next app in dev mode
next dev
, but when I try to build it in production modenext build
, it fails. It gives me the Invalid hook call error (even if I don't use hooks in the library).Any idea what's happening?
Question.
Is it important to export each component as default
We use named exports
ie.
export const Address
And globals
export * from './address/address'
Also we use babel directly instead of rollup
"build": "npm run clean && BABEL_ENV=esm babel components --out-dir esm --source-maps false"
However when we consume the library it does not do the tree shaking
Thank you for this post.
I am struggling with the following problem: One of my components (e.g. Link from ui-library) uses moment as external dependency. As a result, my app contains the whole moment.js even if the app does not use the corresponding component at all.
Do you know how I could solve this?
Thanks for your support.
Hey Linus, I think what you need is the
external
setting of Rollup.rollupjs.org/guide/en/#core-functi...
Thanks for the great write up! I've followed your suggestions but i'm seeing my app build size is the same no matter how many components I import from my ui lib.
Any ideas what could be missing?
Thanks
This is not tree shaking, you will need to preserveModules and set output.dir within rollup to achieve true treeshaking
@michaelbayday can you expand on this please? I've followed this guide and indeed my library isn't being tree shaken.. Thanks!
Thanks for the post, Lukas!
Isn't listing 'sideEffects: false' mandatory in package.json of ui-library. I think you are using webpack 4
WTH Lukas, this article was just recommended to me by a colleague, small world haha. Danke schön.
Honestly, this is just my way of reaching out to you! Glad it finally worked!