loading...
Cover image for How to write a tree-shakable component library

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

lukasbombach profile image Lukas Bombach ・9 min read

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

Discussion

pic
Editor guide
 

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.

 

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

 

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

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

Excellent article Lukas, very informative.

Edit: I have now put in a PR for my company's component library to implement this. πŸ‘

 

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