DEV Community

Carlos Castro
Carlos Castro

Posted on • Updated on

Writing your first React UI Library - Part 1: Lerna

A Dragon with many heads

This is the first post on a series on how to make your own UI React Library.

What are we going to do?

  • Setup a new project using Lerna for multiple packages.
  • Bootstrap all the skeleton project with all the packages we will need.
  • Add necessary dependencies to packages.
  • Wire our own centralized builder.

It might feel sometimes like we are battling with a multi-headed dragon but bare with me as it will pay dividends and the end!

Pre-requisites

  • Node v10+
  • NPM v6+

Lerna

This tool is amazing to manage Javascript libraries with multiple packages, the general idea is that each UI Component in your library will be a completely separate and independent package that can be installed on a project. We are parting ways with a monolithic library where all the code is in a single package in favor of splitting so our clients can install only what they want™

Recommendation is to install it globally since we are going to use few commands from it:

npm i -g lerna
Enter fullscreen mode Exit fullscreen mode

Create Initial project

# example name that I like
mkdir phoenix
cd phoenix

# Initialize empty package.json
npm init -y

# Initialize Lerna
lerna init
Enter fullscreen mode Exit fullscreen mode

Example generated folder structure
folder structure

lerna.json

This is the central lerna configuration for the packages. You can read more about the Lerna configuration here: https://github.com/lerna/lerna#lernajson

Default values

{
  "packages": ["packages/*"],
  "version": "0.0.0"
}
Enter fullscreen mode Exit fullscreen mode

We are going to modify to this

{
  "packages": [
    "packages/*"
  ],
  "version": "0.0.0",
  "hoist": true,
  "stream": true,
  "bootstrap": {
    "npmClientArgs": ["--no-package-lock"]
  }
}
Enter fullscreen mode Exit fullscreen mode

hoist: Makes all dependencies on packages to be lifted up to the root so we de-dupe.
stream: Prints all the inner package logs when run.
npmClientArgs: Prevents generating package-lock.json on all these packages.

packages/

This folder will host all the code of the packages we are going to release.

Let's add some components!

npm scopes

We want to publish these packages to npm and we want to avoid conflicts with others; For this, we will create a scope. Scopes are great to namespace closely related packages or as a form of veracity, for example @babel/core, scope in this case is @babel. For this example we will set the scope to: @cddev.

Read more of scopes here: https://docs.npmjs.com/about-scopes

Important Notes

  • The scope @cddev is the one I used for this guide so it will be already taken, go ahead and create a new one; one that represents your passions and interests in life :)
  • Before using a scope, save yourself a headache and create the org in npm first: https://www.npmjs.com/org/create

Creating packages

Any UI Library is nothing without components so let's create a few packages using Lerna. We will create 4 packages for this guide:

  • @cddev/phoenix: This will hold all the packages together in case someone wants to do a single import.
  • @cddev/phoenix-button: A button component.
  • @cddev/phoenix-text: A Text Component.
  • @cddev/phoenix-builder: A Builder of all components, centralizes rollup, babel, post-css, etc.

Note
we are going to use the default Lerna folder structure on this tutorial.


# Using --yes to skip prompts
lerna create @cddev/phoenix --yes
lerna create @cddev/phoenix-button --yes
lerna create @cddev/phoenix-text --yes
lerna create @cddev/phoenix-builder --yes
Enter fullscreen mode Exit fullscreen mode

Wiring the React components with Lerna

We want build relationships <3 within our components, for example the main phoenix package will import all other packages and export them; We also want to add necessary dependencies to all packages to get started. Let's do it.

# Add phoenix-button dependency into phoenix
lerna add @cddev/phoenix-button --scope=@cddev/phoenix

# Add phoenix-text dependency into phoenix
lerna add @cddev/phoenix-text --scope=@cddev/phoenix

# 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 '{@cddev/phoenix-button,@cddev/phoenix-text}'

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

# We are also going to use an utility to toggle classes as needed on the components called "clsx"
lerna add clsx --scope '{@cddev/phoenix-button,@cddev/phoenix-text}'
Enter fullscreen mode Exit fullscreen mode

With this you should now be able to see some changes across multiple package.json setting pointers among the packages so you can reference them.

Let's write some test React code to export from the UI Components

phoenix-button/lib/phoenix-button.js

import React from 'react';
const Button = ({ children }) => <button>{children}</button>;
export { Button };
Enter fullscreen mode Exit fullscreen mode

phoenix-text/lib/phoenix-text.js

import React from 'react';
const Text = ({ children }) => <p>{children}</p>;
export { Text };
Enter fullscreen mode Exit fullscreen mode

phoenix/lib/phoenix.js

import { Button } from '@cddev/phoenix-button';
import { Text } from '@cddev/phoenix-text';
export { Button, Text };
Enter fullscreen mode Exit fullscreen mode

The Builder

We could go ahead and publish these packages as they are in ES6 but some older clients might not understand this modern Javascript especially because we are using JSX. This needs to be compiled to a format that can be understood by older clients and for that we are going to use a bundler.

Rollup is a good options since it has a minimal API and their docs are great to get started.
https://rollupjs.org/guide/en/

To build these components, wouldn't it be neat to use it like this?

"scripts": {
  "build": "phoenix-builder"
}
Enter fullscreen mode Exit fullscreen mode

In this case the builder will be aware of everything passed to it given the context of where we call it.

For this we are going to be creating a command line executable in node.
https://developer.okta.com/blog/2019/06/18/command-line-app-with-nodejs

Let's modify our @cddev/phoenix-builder/package.json to let node know we are exposing an executable out of this package. In this case the executable is gonna be phoenix-builder.

phoenix-builder/package.json

"bin": {
  "phoenix-builder": "./lib/phoenix-builder.js"
},
Enter fullscreen mode Exit fullscreen mode

Next, we need to make changes into phoenix-builder.js with a dummy command to test things out:

phoenix-builder/lib/phoenix-builder.js

#!/usr/bin/env node
console.log('Woo');
Enter fullscreen mode Exit fullscreen mode

Finally, make the JS executable

chmod +x packages/phoenix-builder/lib/phoenix-builder.js
Enter fullscreen mode Exit fullscreen mode

We should be able to wire the phoenix-builder to our individual components so we have the builder centralized with it's own configuration and be able to run for each component.

lerna add @cddev/phoenix-builder --dev --scope '{@cddev/phoenix,@cddev/phoenix-button,@cddev/phoenix-text}'
Enter fullscreen mode Exit fullscreen mode

And then modify all these packages with a new build script
For example, phoenix-button/package.json

"scripts": {
  "build": "phoenix-builder",
  "test": "echo \"Error: run tests from root\" && exit 1"
},
Enter fullscreen mode Exit fullscreen mode

Next, we should be able to do a test run by doing:

lerna run build
Enter fullscreen mode Exit fullscreen mode

You should be able to successfully see the three Woo messages in the console signaling us that it worked.

Troubleshooting
If you get an error phoenix-builder: command not found make sure you are exporting the bin command in phoenix-builder package.json

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

"scripts": {
 "build":  "lerna run build"
}
Enter fullscreen mode Exit fullscreen mode

With this, we can run at the root npm run build and it should do the same without having to call lerna every time.

Compile the JS with Rollup

Now that we have the builder wired up we can start adding Rollup and all other dependencies we need to compile our code!

Unfortunately Lerna doesn't support adding multiple packages in one command... sigh.

lerna add rollup --scope=@cddev/phoenix-builder
lerna add @babel/core --scope=@cddev/phoenix-builder
lerna add @babel/preset-env --scope=@cddev/phoenix-builder
lerna add @babel/preset-react --scope=@cddev/phoenix-builder
lerna add @rollup/plugin-babel --scope=@cddev/phoenix-builder
lerna add @rollup/plugin-node-resolve --scope=@cddev/phoenix-builder
Enter fullscreen mode Exit fullscreen mode

You should now have all the necessary dependencies to write the phoenix-builder.js

We are going to use the Javascript API in rollup and produce 2 bundles:

  1. CommonJS (CJS) for older clients.
  2. ECMAScript Modules (ESM) for newer clients.

Let's start by modifying phoenix-builder.js with the following code:

phoenix-builder/lib/phoenix-builder.js

#!/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 currentWorkingPath = process.cwd();
const { main, name } = require(path.join(currentWorkingPath, 'package.json'));

const inputPath = path.join(currentWorkingPath, main);

// Little workaround to get package name without scope
const fileName = name.replace('@cddev/', '');

// see below for details on the options
const inputOptions = {
  input: inputPath,
  external: ['react'],
  plugins: [
    resolve(),
    babel({
      presets: ['@babel/preset-env', '@babel/preset-react'],
      babelHelpers: 'bundled',
    }),
  ],
};

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();
Enter fullscreen mode Exit fullscreen mode

Now you can run:

npm run build
Enter fullscreen mode Exit fullscreen mode

And you should be able to see compiled version of our components!
Hurray!!🎉🎉🎉🎉

Example of compiled code in dist folder on each UI Component package

example of compiled code

Conclusion

By now you should have a small library with 2 UI React components; One single library that imports them and a centralized builder. This is the skeleton of the overall UI Library. In the next parts we will work on adding Kitchen Sink/Documentation tooling, CSS Modules support and the final touches to be able to distribute our library.

Resources

Code: https://github.com/davixyz/phoenix/tree/part1
Github: https://github.com/davixyz
Twitter: https://twitter.com/carloscastrodev

Top comments (12)

Collapse
 
hozefaj profile image
Hozefa

Awesome work buddy 👍🏽

Collapse
 
fpaz profile image
Frank 

i'm glad seeing you both here 🍻

Collapse
 
hozefaj profile image
Hozefa

Hey, how are you doing. Great to hear from you...

Thread Thread
 
fpaz profile image
Frank 

I've been great thanks! more than a year DEVing down under :)

Collapse
 
fpaz profile image
Frank 

This is good stuff Carlos! loving this and congrats on your post btw...

Collapse
 
davixyz profile image
Carlos Castro

Thanks a lot Frank! good to hear from you!

Collapse
 
soyandresdev profile image
Andres Hernandez Lozano

Hello, first thank you for your post... Can you help with how able to add other dependence like material/ui in all packages and when I'll try to build compile all dependencies necessary for the package.

Collapse
 
michaelbayday profile image
Michael Dinh

Any tips on implementing with typescript?

Collapse
 
davixyz profile image
Carlos Castro

I was just thinking of that, adding a part 5 to add typescript support, my believe is that we can add a tsconfig and use babel to transform still and produce typings and distribuite them as part of each package, we have this exact implementation at PayPal. Will look into and write something soon!

Collapse
 
karlkras profile image
Karl Krasnowsky

Guess you never got to this.

Collapse
 
vinamrasareen profile image
Vinamra Sareen • Edited

Hi @davixyz, thank you for this amazing post, would it be possible to make a post with lerna version 7 as the support for some commands has been removed, one is mentioned below:

lerna add --scope=

Collapse
 
eliransu profile image
Eliran Suisa

This was a great and straightforward reading, thanks!