React UI Library (Monorepo) — React, Lerna, RollUp, Storybook at MakeMyTrip (Part -2)

Rishi Sahu
6 min readMar 29, 2024

--

Diving Deeper: The Journey to Bringing Our UI Library to Life

Welcome back! In this second installment of our series, we’re rolling up our sleeves to turn our UI library from a great idea into a fully-functional toolkit. It’s time to tackle the real work — making sure our library plays nice with both new and old tech, smoothing out our development process, and getting everything ready for you to use. Let’s break down the steps we’ve taken to ensure a smooth launch.

Setting the Stage for Deployment

Our path to getting this library off the ground involved a few crucial steps:

  • Mapping Compiled JavaScript: We tweaked our package.json files to ensure that whether your project is using the latest JavaScript or something a bit more vintage, our components will fit right in.
  • Configuring Builder and Storybook: A few minor adjustments here mean we can work straight from the source code, making our development life a whole lot easier.
  • Utilizing Lerna for Publishing: Lerna is like the cool tool that helped us get our components neatly packaged and ready to be shared with the world.
  • Final Steps to Shipping (Showtime): At this point, we’re just about ready to go live. Running npm run build packages up all our components, while npm run storybook gives us one last look to make sure everything is chef's kiss.

Now, let’s get into the nitty-gritty of how we made Storybook and our custom builder work even better for us.

Getting Started

Make sure you’ve got Node v10+ and NPM v6+ ready. Lerna will be our orchestrator for this symphony of packages, helping us manage our library’s components with ease. Install Lerna globally with npm i -g lerna to make use of its powerful commands.

Kickoff

Here’s how to get the ball rolling:

mkdir rsuilib
cd rsuilib
npm init -y
lerna init

This sets up our project and Lerna’s home base. We’ll adjust lerna.json to hoist dependencies and streamline package interactions, making our life easier.
Modify your lerna.json to this -

{
"packages": [
"packages/*" //This folder will host all the code of the packages we are going to release.
],
"version": "0.0.0",
"hoist": true, // Elevates all package dependencies to the root level for deduplication purposes.
"stream": true, // Outputs all internal package logs during execution.
"bootstrap": {
"npmClientArgs": ["--no-package-lock"] // Disables the creation of package-lock.json for the specified packages.
}
}

Adding Components

We’re using npm scopes (@rsuilib) to keep our packages neat and organized. This way, clients can pick exactly what they need. Dive into creating our components with:

lerna create @rsuilib/ui-lib --yes
lerna create @rsuilib/ui-lib-button --yes
lerna create @rsuilib/ui-lib-text --yes
lerna create @rsuilib/ui-lib-builder --yes

Wiring It All Together

With Lerna, we connect our components, sharing dependencies where needed and ensuring everything plays nicely together.

# Add rsuilib-button dependency into rsuilib
lerna add @rsuilib/ui-lib-button --scope=@rsuilib/ui-lib

# Add rsuilib-text dependency into rsuilib
lerna add @rsuilib/ui-lib-text --scope=@rsuilib/ui-lib

# We are going to use React for the two UI components, let's add it as dev dependency first for local testing
lerna add react --dev --scope '{@rsuilib/ui-lib-button,@rsuilib/ui-lib-text}'

# And as a peer dependency using major 16 version for consuming applications
lerna add react@16.x --peer --scope '{@rsuilib/ui-lib-button,@rsuilib/ui-lib-text}'

# We are also going to use an utility to toggle classes as needed on the components called "clsx"
lerna add clsx --scope '{@rsuilib/ui-lib-button,@rsuilib/ui-lib-text}'

In our journey to create a user-friendly UI library, we come across a challenge: our code is modern, but we need it to work everywhere, even on older browsers. That’s where our Builder comes in.

The Builder’s Role

Our code is written in the latest JavaScript (ES6) and uses JSX, which might confuse some older browsers. To make sure everyone can use our library, we need to translate our code into a more universally understood language. This process is called bundling, and for that, we use a tool called Rollup. It’s simple to use and has everything we need.

To make things even easier, we thought, “Wouldn’t it be great if we could just type one command to build everything?” So, we created a special command that does just that. We call it rsuilib-builder.

First, we tell our package, @rsuilib/ui-lib-builder/package.json, that we're creating a new command:

"bin": {
"rsuilib-builder": "./lib/rsuilib-builder.js"
}

Then, we write a little test in @rsuilib/ui-lib-builder/rsuilib-builder.js to make sure it works. When we run it, it should say "Hello Bob the builder".

  rsuilib-builder/lib/rsuilib-builder.js
#!/usr/bin/env node
console.log('Hello Bob the builder');

Finally, make the JS executable
chmod +x packages/rsuilib-builder/lib/rsuilib-builder.js

We aim to integrate the rsuilib-builder into individual components, centralizing the builder with its own configuration for each.

lerna add @rsuilib/ui-lib-builder --dev --scope '{@rsuilib/ui-lib,@rsuilib/ui-lib-button,@rsuilib/ui-lib-text}'

Following this, we adjust each package with a new build script. For instance, in rsuilib-button/package.json:

"scripts": {
"build": "rsuilib-builder",
"test": "echo \"Error: run tests from root\" && exit 1"
}

Next, we should be able to do a test run by doing:
lerna run build
To make the running of the script easier, at the root level let’s modify our scripts in our package.json and add: root/package.json
With this, we can run at the root npm run build and it should do the same without having to call lerna every time.


"scripts": {
"build": "lerna run build"
}

Compile the JS with Rollup

With the builder set up, we’re ready to add Rollup and other required dependencies for code compilation. However, it’s worth noting that Lerna doesn’t support adding multiple packages in one command.

lerna add rollup --scope=@rsuilib/ui-lib-builder
lerna add @babel/core --scope=@rsuilib/ui-lib-builder
lerna add @babel/preset-env --scope=@rsuilib/ui-lib-builder
lerna add @babel/preset-react --scope=@rsuilib/ui-lib-builder
lerna add @rollup/plugin-babel --scope=@rsuilib/ui-lib-builder
lerna add @rollup/plugin-node-resolve --scope=@rsuilib/ui-lib-builder

We are going to use the Javascript API in rollup and produce 2 bundles:
CommonJS (CJS) for older clients.
ECMAScript Modules (ESM) for newer clients.

Fine-Tuning Our Builder

As our base is all set, we needed our builder to prioritize the latest version of our source code. This way, we’re always working with the most up-to-date versions of our components. It was all about making sure that when we say “build,” it knows exactly which code we’re talking about.

Here’s a little behind-the-scenes look at the changes we made:

#!/usr/bin/env node
const rollup = require('rollup');
const path = require('path');
const resolve = require('@rollup/plugin-node-resolve').default;
const babel = require('@rollup/plugin-babel').default;
const postcss = require('rollup-plugin-postcss');
const currentWorkingPath = process.cwd();
// Little refactor from where we get the code
const { src, name } = require(path.join(currentWorkingPath, 'package.json'));
// build input path using the src
const inputPath = path.join(currentWorkingPath, src);
// Little hack to just get the file name
const fileName = name.replace('@cddev/', '');
// see below for details on the options
const inputOptions = {
input: inputPath,
external: ['react'],
plugins: [
resolve(),
postcss({
// Key configuration
modules: true,
}),
babel({
presets: ['@babel/preset-env', '@babel/preset-react'],
babelHelpers: 'bundled',
exclude: 'node_modules/**',
}),
],
};
const outputOptions = [
{
file: `dist/${fileName}.cjs.js`,
format: 'cjs',
},
{
file: `dist/${fileName}.esm.js`,
format: 'esm',
},
];
async function build() {
// create bundle
const bundle = await rollup.rollup(inputOptions);
// loop through the options and write individual bundles
outputOptions.forEach(async (options) => {
await bundle.write(options);
});
}
build();

Making Storybook Work for Us

Storybook is our sandbox for UI components, but we stumbled upon an issue: it preferred compiled code over our fresh edits. Not great for speedy iterations! We tweaked Storybook to focus on new changes.

Now, every tweak we make is immediately live in our Storybook environment, letting us experiment and adjust on the fly without any extra steps.

Here’s a peek at how we did it:

module.exports = {
stories: ['../packages/**/*.stories.js'],
addons: ['@storybook/addon-actions', '@storybook/addon-links'],
webpackFinal: async (config) => {
// remove default css rule from storybook
config.module.rules = config.module.rules.filter((f) => f.test.toString() !== '/\\.css$/');

// push our custom easy one
config.module.rules.push({
test: /\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
// Key config
modules: true,
},
},
],
});
// This is where we change the order of resolution of main fields
config.resolve.mainFields = ['src', 'module', 'main'];

// Return the altered config
return config;
},
};

We are now ready to publish

Run : — lerna publish
Congrats! Your library has been published

So, there you have it! Our setup is now ready to welcome new packages and let them shine on their own, making our UI development journey a bit smoother.

Sharing my github which has a basic implementation of the UI Library
https://github.com/rishisahu20/ui-library

Wrapping Up

By giving Storybook and our builder a bit of a makeover, we’ve made our development process slicker, quicker, and just plain better. These changes mean we can stay in the zone, focusing on creating and refining our UI components without getting bogged down by the tedious stuff.

--

--

Rishi Sahu

Sr. Software Engineer @MakeMyTrip | Full Stack Developer (Web & App) | Developing web & mobile apps for 5+ years in Product & Service industry