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
Create Initial project
# example name that I like
mkdir phoenix
cd phoenix
# Initialize empty package.json
npm init -y
# Initialize Lerna
lerna init
Example generated 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"
}
We are going to modify to this
{
"packages": [
"packages/*"
],
"version": "0.0.0",
"hoist": true,
"stream": true,
"bootstrap": {
"npmClientArgs": ["--no-package-lock"]
}
}
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
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}'
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 };
phoenix-text/lib/phoenix-text.js
import React from 'react';
const Text = ({ children }) => <p>{children}</p>;
export { Text };
phoenix/lib/phoenix.js
import { Button } from '@cddev/phoenix-button';
import { Text } from '@cddev/phoenix-text';
export { Button, Text };
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"
}
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"
},
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');
Finally, make the JS executable
chmod +x packages/phoenix-builder/lib/phoenix-builder.js
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}'
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"
},
Next, we should be able to do a test run by doing:
lerna run build
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"
}
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
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:
- CommonJS (CJS) for older clients.
- 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();
Now you can run:
npm run build
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
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)
Awesome work buddy 👍🏽
i'm glad seeing you both here 🍻
Hey, how are you doing. Great to hear from you...
I've been great thanks! more than a year DEVing down under :)
This is good stuff Carlos! loving this and congrats on your post btw...
Thanks a lot Frank! good to hear from you!
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.
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=
Any tips on implementing with typescript?
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!
Guess you never got to this.
This was a great and straightforward reading, thanks!