How to write a tree-shakable component library
Tags: react, typescript, design
Published: 2020-02-06 15:33:42


> **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](https://2ality.com/2019/04/nodejs-esm-impl.html) and is currently [flagged as experimental](https://nodejs.org/api/esm.html#esm_ecmascript_modules).

Anyhow, using Webpack/Babel or TypeScript you will all be familiar with this format. It is the format that lets you write

```ts
import X from "y";

export Z;
```

👆 ESM

instead of CJS 👇

```js
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](https://v8.dev/blog/cost-of-javascript-2019).

## 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

```js
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 

```ts
import { Button } from '@material-ui';
```

You will do this

```ts
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

```ts
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](https://github.com/ant-design/babel-plugin-import) and use a [bonkers setup](https://ant.design/docs/react/use-with-create-react-app#Advanced-Guides) with `create-react-app` that requires you to rewire your `react-scripts`. What this babel plugin does is automatically translate this

```ts
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`:

```bash
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:

```json
{
  "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:

```bash
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:

```json
{
  "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`


```bash
touch rollup.config.ts
touch tsconfig.json
```


**rollup.config.ts**
 
```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** 

```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:

```js
{
  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

```bash
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** 

```ts
export { default as Button } from "./Button/Button";
```

**Button.tsx**

```tsx
import React from "react";

export default () => <button>I SHOULD BE HERE</button>;
```

#### 🎉 🎉 🎉 Now you can run 🎉 🎉 🎉

```bash
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:


```js
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:


```bash
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 `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 ✌️