How to create a lean, tree-shakeable React Design System library

Guillaume Cornut
LumApps Experts
Published in
7 min readJul 15, 2021

Co-authored with: Pierre Dupuis

The big rock (our old monolithic UMD bundle) and the small tree (our new small tree-shakeable lib)

Introduction

LumApps Design System

At LumApps, we are developing LumX, our own design system which is a central repository containing not only our components but also our design patterns and guidelines. This is a huge project that dates back to the end of 2018, aiming to replace our home-made UI Kit while doing so much more in the process.

If you want to know more about this project, you should take a look at our article on how we started building a design system [1].

Since our UI Kit was in AngularJS and since we are currently migrating our entire product from AngularJS to React, we do need to maintain our Design System in both these frameworks. Our road to React is a great story itself, check it out in our dedicated article [2].

For the purpose of this introduction, let’s assume we are in 2020. It has been almost two years since the beginning of the project and our Design System is still in a 0.x alpha version. Up until this moment, we have developed a lot of components and we could continue to add more, but since our partners needed to use our Design System, we needed to release its first stable version.

Apart from having to finish some components, to improve the overall codebase and to be more accessible, we need to improve our performance. One way of doing it is to improve our bundling configuration.

What was wrong with our configuration

At the time of the 0.x alpha version, our Design System setup is not that bad. Since our component library is developed using multiple javascript frameworks, our Design System follows a monorepo architecture with separated independent packages for each framework and one for our demo site.

We decided to use Typescript soon enough to have our entire codebase typed. And we can benefit from a pretty neat storybook environment to ease our development.

The architecture is pretty clean and the development environment is efficient, so what could be wrong? Our bundling configuration. We had a complex webpack configuration leading to a single browser-ready UMD (Universal Module Definition) bundle full of polyfills. This is doubly wrong because we knew we would never need to directly import LumX into a browser and we need to handle the browser compatibility at an application level already.

Also, the monolithic UMD bundle meant that someone wanting to only use our button component using the following syntax would end up having our entire Design System in their app bundle.

Tree-shaking in javascript

When it comes to building a Javascript application, there are a lot of different module formats available from CJS (CommonJS) to ESM (ES2015 Modules) through UMD (Universal Module Definition).

We won’t explain every specificity of those modules. The important thing to know is that ESM modules can be statically analyzed to allow build tools (like Webpack or Rollup) to perform tree-shaking on the code — tree-shaking being a process that removes unused code from bundles [3]. This means that, in the case previously stated, if someone wants to use our button component using the same syntax as before, only this component would end up in their app bundle.

Another approach would have been to create one bundle (UMD for example) for each component or resource we want to expose. This solution, used by Lodash and MaterialUI for instance, can be a good alternative to ESM modules if you want your modules to be compatible with every browser without transpilation.

However, this solution requires a complex bundle configuration to avoid including duplicate code inside the different micro-bundles and it asks the developer to be more precise during the import like this:

We decided to go with the tree-shakeable ESM solution. If you want to learn how to create a tree-shakeable component library step by step, you should have a look at the amazing resource that helped us a lot along the way [4].

What did we want to achieve

Our goal was pretty straight-forward: we wanted to move from our ugly browser-ready UMD bundle full of polyfills to the smallest possible tree-shakeable ESM library.

As we are developing in TypeScript we do need some transpilation but we want to keep ESM modules for tree shaking and reduce polyfills.

Also, and this objective will have a direct impact on our bundle configuration, we need to be able to decide precisely what we want to expose as the API of a library should be kept under surveillance for potential breaking changes that we absolutely want to avoid after releasing the first version.

Finally, we want to watch what libraries are bundled with our lib and avoid re-exporting libraries commonly used outside the lib.

Methods

Rollup, ESM and React-TS transpilation

As we need to continue to use advanced bundling configuration for the Lumapps app (using Create React App and Webpack) we would rather keep the bundling of the LumX project as simple as possible.

As such, we chose to migrate from Webpack to Rollup because it is more suitable for ESM libraries by design and was simpler to configure for our needs. It tends to be pretty common to use Rollup for libraries and Webpack for apps [5].

The project being a React component library written in TypeScript, we decided to use a simple babel transpilation config with `@babel/preset-env` for basic EcmaScript transpilation and browser polyfills, `@babel/preset-react` for React TSX/JSX transpilation and of course `@babel/preset-typescript` for TypeScript transpilation.

Concerning polyfills, we decided to reduce them at the minimum with the Browserslist’s default browsers (> 0.5%, last 2 versions, Firefox ESR, not dead) [6].

The resulting rollup configuration goes as followed:

Splitting the output chunks

For an even better tree-shakeable library, we decided to also split the generated bundle in multiple files. One file per component.

To do this, we list all component files and add them in the rollup input indexed by component name:

By doing that, we also created multiple chunks in output but we want to only expose the `@lumx/react` module to the outside world. The chunks for each component and the chunks automatically generated by rollup should not be accessible.

One simple solution was to rename all the chunks that are not the root index module so that they are moved to an `_internal` directory which will discourage users of the library from importing them in their code.

The code for the output files is the following:

Well chopped up ESM modules!

Avoiding re-exporting libraries

One trick we also used to reduce the generated bundle size was to look at the libraries we imported in LumX that we also used in the main application and externalize them.

The obvious library that does not need to be re-exported is, of course, `react` and `react-dom`. We previously already excluded react from the bundle but we pushed this further by also externalizing `lodash` and `moment` from the bundle.

To externalize these dependencies, the cleanest way is to declare them as peer dependencies in the `package.json`:

Moving dependencies in the `peerDependencies` field of the `package.json` prevents them to be installed automatically when installing LumX and can be useful to update these libraries without having to update them in LumX first.

One down side of this is that the dependencies are not installed when working in the LumX repository. A solution to this is to add the npm-install-peers util in the `prepare` npm script that only executes after a `npm install` or `yarn install` locally and not in the project in which you import LumX.

Example of use in the `package.json`:

Then, to externalize these peer dependencies from the bundle, you can use this rollup plugin:

import `peerDepsExternal` from `rollup-plugin-peer-deps-external`;

Exporting type definitions

With the previously described configuration we can publish an ESM module in which we choose what to expose and how. But with the transpilation we also lost the TypeScript type definitions that we would want to publish in the NPM package alongside the ESM module.

If you just generate declaration files with the typescript compiler, you will get a declaration file for each TypeScript file in the repository. But, like mentioned before, we only want to expose one module, the `@lumx/react` module.

To do this, we used the dts-bundle-generator utility and configured it as a `postbuild` npm script in the `package.json`:

Results

We compared the `dist` folder content and size before and after the new bundling configuration and the results speak for themselves.

The previous UMD bundle looked like this:
https://bundlephobia.com/result?p=@lumx/react@0.28.1

The final tree-shakeable ESM bundle looks like this:
https://bundlephobia.com/result?p=@lumx/react@1.0.0-alpha.5

References

  1. https://medium.com/lumapps-engineering/how-we-started-building-a-design-system-at-lumapps-f360fcc9a8ce
  2. https://medium.com/lumapps-engineering/lumapps-road-to-react-ee0cc96af26e
  3. https://code-trotter.com/web/understand-the-different-javascript-modules-formats/
  4. https://dev.to/lukasbombach/how-to-write-a-tree-shakable-component-library-4ied
  5. https://medium.com/webpack/webpack-and-rollup-the-same-but-different-a41ad427058c
  6. https://github.com/browserslist/browserslist#full-list
  7. https://github.com/lumapps/design-system/blob/v1.0.0-alpha.5/packages/lumx-react/rollup.config.js

--

--