DEV Community

Cover image for How to write a tree-shakable component library
Lukas Bombach
Lukas Bombach

Posted on

Tree Shaking in React How to write a tree-shakable component library

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 pages/index.tsx like so

index.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:

yarn dev

You should see this now:

Next App with Component visible on the screen

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

cd packages/app
yarn dev

and

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)

Collapse
 
dance2die profile image
Sung M. Kim

Thanks for the post, Lukas.

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,...

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.

Collapse
 
lukasbombach profile image
Lukas Bombach

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.

Collapse
 
dance2die profile image
Sung M. Kim

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/...

Collapse
 
masood1 profile image
masood hussain

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

Collapse
 
iwarner profile image
Ian Warner

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"

esm: {
  presets: [
    [
      '@babel/preset-env',
      {
        modules: false
      }
    ]
  ],
  plugins: [
    [
      '@babel/plugin-transform-runtime',
      {
        useESModules: true
      }
    ]
  ],
  ignore: ['**/__tests__', '**/__mocks__', '**/Storyshots.test.js']
}
Enter fullscreen mode Exit fullscreen mode

However when we consume the library it does not do the tree shaking

Collapse
 
squgeim profile image
Shreya Dahal

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 mode next 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?

Collapse
 
uriklar profile image
Uri Klar

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

Collapse
 
lihue profile image
Linus HΓΌsler

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.

Collapse
 
lukasbombach profile image
Lukas Bombach • Edited

Hey Linus, I think what you need is the external setting of Rollup.

rollupjs.org/guide/en/#core-functi...

// rollup.config.js
export default {
  entry: 'src/index.js',
  dest: 'bundle.js',
  format: 'cjs',
  external: [ 'moment' ] // <-- add moment to your "external" modules
};
Collapse
 
michaelbayday profile image
Michael Dinh

This is not tree shaking, you will need to preserveModules and set output.dir within rollup to achieve true treeshaking

Collapse
 
uriklar profile image
Uri Klar

@michaelbayday can you expand on this please? I've followed this guide and indeed my library isn't being tree shaken.. Thanks!

Collapse
 
shanazkapil profile image
Kapil Shanaz

@lukasbombach
can we bundle pages using rollup?

Collapse
 
adrianrivers profile image
VanillaGorilla

WTH Lukas, this article was just recommended to me by a colleague, small world haha. Danke schΓΆn.

Collapse
 
lukasbombach profile image
Lukas Bombach

Honestly, this is just my way of reaching out to you! Glad it finally worked!

Collapse
 
ajnior profile image
Agnaldo Cardoso Junior

Thanks for the post, Lukas!

Collapse
 
truedrug profile image
Sushree

Isn't listing 'sideEffects: false' mandatory in package.json of ui-library. I think you are using webpack 4