Skip to content

A Node.js/React library build guide, with complete explanation of each decision taking.

Notifications You must be signed in to change notification settings

advename/The-React-Library-guide

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

17 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🚧 Under construction 🚧

This repo is under construction.

For the incoming reviewers, here are some bullet points:

  • feel free to fork and make change requests
  • nothing has to stay, everything may be updated
  • As for now, I used inline footnotes, e.g. ^[[Text for footnote](www.example.com)], which unfortunately are not supported in GitHub. I'll fix this in the next days
  • English is my fourth language, permission granted to mock my English :D
  • If terms are used incorrectly, please point me out
  • I aimed for a package.json with "type":"commonjs" - thereby providing the greatest compatibility for older Node.js versions. What are your thoughts on the "goal package.json".
  • Cherry picking may be erroneous and needs rework - or please point me out to what Cherry picking actually refers to.
  • The code examples are not finalized yet - please ignore the files in my-lib and my-app. I'm still playing around with them.
  • Please pour as much knowledge as possible into this guide - as it will not only help me, but likely the whole dev community (okay, okay,... I'll jump off my high horse now)
  • And, thank you all so much for answering questions, the support and the time taken to look over it! Regardless of how much you will contribute from now on, I'll add any single one of you guys to the Contributors section during the release (unless inform me not to shame your name)

The open source Node.js/React library guide

Building a Node.js package is no easy task and requires some foundation knowledge. Existing articles and guides often omit the thoughts and reasons behind decisions, making it harder to debug or implement new features.

This open-source guide aims at closing down this gap, by clarifying topics required to understand later thoughts.

At the end of this guide, you're able to build a Node.js/React library with:

  • ES, CJS, UMD, AMD, IIFE modules support
  • React and Typescript support (.js, .jsx, .ts, .tsx, ...)
  • handling images
  • handling styling
  • minification

This guide uses "library" and "package" interchangeably. After all, a React library is a Node package, hosted on a package registry like www.npmjs.com.

Table of contents

1.0.0 – Foundation

It's suggested to read through the following subjects, even if you already know about some of them, as we're going to cover some more in detail.

1.1.0 – JavaScript Environments

Historically, JavaScript (JS) was created in 1995^[JavaScript | wikipedia.com], to provide interactivity to the Netscape browser. A decade later and JavaScript was the standard for web browsers like Internet Explorer, Firefox, Chrome, Safari, … . Even though that JavaScript worked perfectly fine in the browser at that time, it did not provide a great development experience. This led to the creation of developer tools which improved said experience by adding a modular system to the browser.

Terminology

  • Modular system Instead of having one large JavaScript file, split it up in several smaller ones and use the methods/values accross the files. The next section explains this topic in more detail.
  • Bundler is a tool that "bundles" (= combines) and optimizes multiple files into one or more file(s) that which are better suited for production environments. E.g. webpack, rollup, esbuild, Browserify are bundlers
  • Compilers and Transpilers are nearly the same tool types, that transform code from one language or a different version of the same language. Sometimes they are called Transcompilers E.g. Babel or Typescript are compiler/transpilers
  • Module Loader is a tool that "loads" modules of a certain module system type such as AMD, manages their dependencies and executes the application. E.g. RequireJS is a module loader

Around the same time in 2009, Node.js was born, bringing JavaScript to the server side and evolved into being the most popular back-end JavaScript runtime environment.

In this context, one can think of Node.js and Browserify as being the same, where the latter "brings npm to the browser". RequireJS is a tool only used in the browser, bringing a modular system to the browser that can load dependencies asynchronously.

Why am I saying all of this? Because it's important to know where your React package may end up being used.

With this in mind, the next section introduces the different JavaScript module systems and looks at the integration or compatibility of these, in Old Browsers (ES5), New Browsers (ES6), Old Node.js (before version 13.2.0) and New Node.js (after version 13.2.0) and RequireJS.

1.2.0 – JavaScript Modules

Because that the size of these codes grown bigger and more complex in the past years, it made sense to split up JavaScript code into separate modules that can be imported when needed.^[JavaScript modules | developer.mozilla.org]

Meaning, a Browser application that imports several script files

<script src="main.js"></script>
<script src="shop.js"></script>
<script src="cart.js"></script>
...

produces several issues:

  • All variables and functions names are exposed to the global scope (sometimes known as the namespace or global window object). E.g., if each file had a function named function init(){...}, then we would run into conflicts.
  • The scripts must be loaded in correct order. E.g., cart.js has a variable with an array that keeps' track of the items in the cart. shop.js, which loads before cart.js, uses that array variable to display an "Add again" instead of "Buy" button.
  • Becomes difficult to manage, even more when adding third-party packages, and so even more when these have peer dependencies. E.g. Bootstrap depended on jQuery until version 5.^[Introduction · Bootstrap]

1.2.1 – Module Systems

The basic concepts of a module system was to apply the following patterns:

  • encapsulate code, so that the code is only available on a local scope
  • define an interface, through which we can access code from a different location
Module System Appearance Description Integration
Immediately Invoked Function Expression(IIFE) ~2010
• exports only if defined to global scope
• also known as a self executing anonymous function
Works in all JavaScript environments
Old Node.js
New Node.js
Old Browsers
New Browsers
RequireJS
CommonJS (CJS) 2009 • exports with module.exports
• imports with require()
• synchronous module loading
• designed for general purpose JavaScript environment^[Book, Secrets of the JavaScript Ninja, Second Edition, Chapter 11.1.2]
• implemented in Node.js and therefore received the saying that it's the "server side" format
• CJS code is exposed to a local scope and does not pollute the global scope
Old Node.js
• integrated by default ^[Modules: CommonJS modules | Node.js v17.2.0 Documentation]
New Node.js
• integrated by default ^[Should I prefer ES modules to CommonJS? · Issue #2267 · NodeJS/help · GitHub]
Old Browsers
• not supported, but works with the help of Browserify, that bundles to a self-contained format which has everything the application needs to run
New Browsers
• same as old browser
RequireJS
• not supported (there exists partial support, which should be ignored in this context ^[javascript - Difference between RequireJS and CommonJS - Stack Overflow] ^[CommonJS Notes] ^[RequireJS in Node])
Asynchronous Module Definitions (AMD) 2010^[First AMD Proposal wiki.commonjs.org ] ~ 2011^[First AMD API commit github.com] • exports with define()
• imports with require()
• asynchronous module loading (i.e., "lazy loading")
• early fork of CommonJS^[Book, Building Enterprise JavaScript Applications packtpub.com, Chapter 4]
• explicitly built for the browser
• less popular compared to CJS due to a more complex syntax^[Book, Front-End Tooling with Gulp, Bower, and Yeoman manning.com, Chapter 9.2]
Old Node.js
• Not supported
• possible to write AMD in Node.js for later browser usage^[RequireJS in Node] using amdefine
New Node.js
• same as old Node.js
Old Browsers
• Not supported
New Browsers
• Not supported
RequireJS
• integrated by default and works on runtime, meaning compared to Browserify does not require a bundle process to work in the browser.
Universal Module Definition (UMD) 2011^[First UMD API Commit · GitHub] • created to support all (at the time available) JavaScript environments, meaning CJS, AMD and Browsers
• checks the environment during runtime and then deploys the corresponding module format, or fallbacks to make the module functionality available as variable in the global scope to support Browsers
• uses AMD as a base with special casing added to handle CJS compability^[GitHub - umdjs/umd: UMD (Universal Module Definition) patterns for JavaScript modules that work everywhere.]]
• EMS (next module system below) is not supported in UMD
Old Node.js
• supported, resolves to CJS
New Node.js
• supported, resolved to CJS
Old Browsers
• supported, resolves to IIFE
New Browsers
• supported, resolved to IIFE
RequireJS
• supported, resolved to UMD
ES2015 Modules (ESM/ES modules) 2015 • exports with exports
• imports with import
• formerly known as ECMAScript 6 / ES6^[ECMAScript2015 - Wikipedia]
• synchronous and asynchronous module loading
• first official JavaScript Module specification, which means browser and Node.js will eventually support it (which it does by now except Internet Explorer)
• even a more pleasing syntax than CJS
Old Node.js
• not supported (after version 9.6.0: experimental support exists (released in 2018) ^[Node v9.6.0 (Current) Node.js]) New Node.js
• integrated, requires "type": "module" in package.json
Old Browsers
• not supported, requires polyfill
New Browsers
• supported, requires <script type="module">"
RequireJS
• not supported

(Above table is based on the following additional sources: Source 1 | Source 2 | Source 3 | Source 4 | Source 5 | Source 6 | Source 7 | Source 8 | Source 9 | Source 10 | Source 11)

Notes:

  • The reason why CJS is/was much more popular on the server side, is due to it's synchronous nature. On the server side, module fetching is relative quick since the modules exist locally, compared to the client-side, where the module has to be downloaded from a remote server, and where synchronous loading usually meant blocking.^[Book, Secrets of the JavaScript Ninja, Second Edition, Chapter 11.1.2]
  • ES5 is also known as ECMA-262 5th Edition and was released in 2009^[ECMA-262 Edition 5.1]
  • Some people might refer AMD and CJS as "ES5 Modules", which is wrong. It wasn't until ES2015 that module formats had been standardized (see footnote^[ECMA-262 Edition 5.1] which does not mention Modules in the specification), where both Node.js and Browsers started implementing the ES2015 standard which now is supported by all browsers (except Internet Explorer).^[JavaScript modules - JavaScript | MDN]
  • browsers have partial native support for AMD, but only allow to define a module and not import one^[How to write an AMD module for use in pages without RequireJS? | stackoverflow.com]

1.2.2 – Syntaxes

Most likely, ESM and maybe CJS are the only formats you will ever develop in. It's still a plus to be able to detect a module system based on its format.

IIFE

The IIFE syntax is fairly simple.

  • Wrap a function inside parentheses and append parentheses next to the first one (function () {...})()
  • Add the module pattern by returning a value inside the function and assigning it to a variable const myValue = (function () { return ... })(), et voilà, there you have the IIFE
const mathFuncs = (function () {
  return {
    add: function (a, b){
      return a + b;
    }
  }
})();

console.log(mathFuncs.add(1,1));

The above approach minimizes global scope pollution and allows organizing entire modules.

CJS

CJS exports are done with module.exports and the exported value type can be any primitive (string, number, ...), object, array or method. /add.js

module.exports = function add(a + b) {
    return a + b
}

A user would simply import the module like this.

/index.js

const add = require("./add.js");
console.log(add(1, 1)); // -> 2

AMD

AMD syntax is probably the most difficult one to understand, so lets go through the define()^[RequireJS API] syntax definition first.

define(id?, dependencies?, factory);

Where:

  • id - optional argument which specifies the name of the module being defined. If not specified, then the module name is its file location + name. It's common not to specify a name and just use the file location + name.
  • dependencies - optional array of dependencies. If not specified or empty means that the module has no dependencies.
  • factory - the function to run in this module. It should be noted that the factory function must return a value, which can be any type of primitive, object, array or method.

Second, the require()^[RequireJS API] syntax definition.

require(dependencies?, callback);

Where:

  • dependencies - optional array of dependencies. If not specified or empty means that the module has no dependencies.
  • callback: A callback function that’s executed when the (optional) dependencies modules are loaded

Two important things to know here is that there is:

  • one way to define a module, and that is with the define() method.
  • two ways to import a module, and that is with the define() and require() method. The difference between them is that define() is never executed unless it has been imported by a require() method. require() is what triggers executions. It is generally only used once in the top level of your application, and serves as the entry point which then calls the rest of your application. It can as well by used anywhere to execute an immediate callback method^[Dojo require vs define | Dimitri's tutorials ]

Let's define a module. /utils/add.js

define(function (a + b) {
    return a + b
});

And import it with the define() method. /app.js

define(["utils/add"] , function (add) { // dependencies are available as parameters, comma seperated
   console.log(add(1, 1)); // -> 2
   return null; // satisfy the return condition of define()
});

By now, nothing happened. We have to trigger the execution using the require() method.

/index.js

require(["app"] , function (app) {
  // We don't have to do anything here now.
  // The app module has now been loaded and the console.log
  // has been triggered, i.e. the console shows "2" from the add method.
});

UMD

UMD has many format variations, called templates. They distinguish in conditional statements that check to see which module system is in use in the current environment, if any. Thus, there are some templates that support AMD and Browsers, or Node.js and AMD,... or all three of them.

A basic UMD format looks like:

(function (root, factory) {
    // root is a reference to "this", the global scope
    // factory is the function where we define the module
    
    // Check for environment
    if (typeof define === 'function' && define.amd) {
        // AMD. Register as an anonymous module.
        define(['exports', 'b'], factory);
    } else if (typeof exports === 'object' && typeof exports.nodeName !== 'string') {
        // CommonJS
        factory(exports, require('b'));
    } else {
        // Browser globals
        factory((root.commonJsStrict = {}), root.b);
    }
}(typeof self !== 'undefined' ? self : this, function (exports, b) {
    // The dependency named "b" is now available to our module

    // attach properties to the exports object to define
    // the exported module properties.
    exports.action = function () {};
}));

Primarily, you would program in ESM or CJS these days and use a bundler or transpiler to transform the code to UMD for browser usage. Most bundlers and transpilers (e.g. Webpack, Rollup, Babel,...) these days use a UMD template, which supports all environments, aka AMD, CJS and Browsers.

ESM

This is probably the syntax you are most familiar with. import and export can only be used in the top-level.

top-level does not mean the import and export is at the top of the file. It just means the outer most scope in a file and not be wrapped inside any functions or conditionals.

/add.js

export function add(a + b) {
    return a + b
}

/index.js

import {add} from "add";
console.log(add(1, 1)); // -> 2

1.2.3 – Dynamic vs Static module loading

In dynamic module loading, imports and exports are resolved at runtime. Therefore, imports and exports can be loaded inside functions or conditionals.

In static module loading, imports and exports are resolved during compile time - that is, before the script starts executing (=runtime). Hence, making it impossible to wrap imports and exports in conditionals since the compiler does not know the state of the condition (only known during runtime) ^[Static module resolution].

Let's have a look at the following CJS example^[Stolen from here] , where you have to run the code to determine what it imports:

var mylib;
if (Math.random()) {
    mylib = require('foo');
} else {
    mylib = require('bar');
}

Until ESM, you had to execute code to find out what module was loaded or not. Static module loading gives you less flexibility, but it comes with several benefits.

For now, keep this in mind:

  • ESM is a static module system - modules are identified during compilation time - uses the import statement
  • CJS is a dynamic module system where imports can be wrapped in methods and conditionals - modules are identified during runtime - uses the require() function

What about AMD? AMD is also a dynamic module loader as a result of its asynchronous nature. And while it's a dynamic module loader, you can also conditionally load modules with some "hacks". ^[What are AMD modules? Fetch your sick bag] ^[AMD - Learning JavaScript Design Patterns [Book]] ^[javascript - How to achieve lazy loading with RequireJS? - Stack Overflow]

Import specifiers

A specifier is the identifier of the module, e.g.

import MODULE from "<specifier>";

There are three types of specifiers:

  • relative specifier - a path relative to the location of the current file. File extensions are always necessary for relative specifiers. E.g., import { startup } from "./startup.js"
  • bare specifier - the name of a package. Does not require the inclusion of the file extension. E.g., import { last } from "lodash"
  • absolute specifier - the full file path of the module. Also require the file extensions. E.g. import { config } from "file:///opt/nodejs/config.js"

ES2020 Dynamic import() function

Then in 2020, the TC39 comity (the people governing ECMAScript/JavaScript specifications) released ECMAScript 2020 (also known as ES2020), along with dynamic module loading. ^[GitHub - tc39/proposal-dynamic-import: import() proposal for JavaScript] ^[https://exploringjs.com/impatient-js/ch_modules.html#dynamic-imports] Following this, the new import() expression returns a promise.

The dynamic import() expression can be seen as a "function", may helping to highlight the difference. ^[https://nodejs.org/dist./v13.14.0/docs/api/esm.html#esm_import_expressions]

  • dynamic import()is an expression (function)
  • static import is a statement
  • dynamic require() is a function (and not an expression)
if (Math.random()) {
    import("foo").then(fooModule => {
        // do stuff with foo
    })
} else {
    import("bar").then(barModule => {
        // do stuff with bar
    })
}

Or inside an async function, which provides a nicer syntax for promises.

(async () => {
  let myLib;
  if (Math.random()) {
      myLib = await import("foo");
  } else {
      myLib = await import("bar");
  }
  // do something with the loaded module
})();

Now, ES modules can be imported either with the import statement (static) or via the import() expression (dynamic). On top of that, this allows us to import ES modules into ESM and CJS files, something that was not possible before. ^[https://techsparx.com/nodejs/esnext/dynamic-import-2.html] ^[https://2ality.com/2019/04/nodejs-esm-impl.html#importing-esm-from-commonjs] ^[https://blog.logrocket.com/es-modules-in-node-today/]

While it seems obvious that one can import CJS modules to CJS files or ES modules to ESM files, with the new ES2020 dynamic feature it is now also possible to import ES modules to CJS files and CJS modules to ESM files. Before, it was only possible to import CJS modules to ESM.

Import CJS modules to ESM files

Consider the following CJS file. utils.js

module.exports = {
  foo: 123
}

An ESM file can import the above module with index.js

import utils from "./utils";
console.log(utils.foo); // -> 123
import { foo } from "./utils"; // Fails

Importing a CJS module to ESM files only works with default imports import lodash from "lodash", meaning you can't use named exports import { last } from "lodash".

Import ESM modules to CJS files

Consider the following ESM file. utils.js

export const bar = 123;

A CJS file can import the above module with index.js

(async () => {
  const utils = await import("./utils");
  console.log(utils.bar); // -> 123
})();

Interoperability, named and default Exports

Interoperability or sometimes shortened to "interop" specifies how CJS and ESM work together.

Node.js ES modules can export a default and any number of named exports at the same time.

// file.js
export const a = 1;
export const b = 2;
export default = 3;

Making the following possible in ESM files import banana, {a,b} from "./file where banana equals 3.

Node's CJS implementation, on the other hand, allows for a default export or any number of named exports, but not both together**.

Transpilers tried to fix this conflict between ESM and CJS by sugarcoating ES modules with default exports. Default exports exist to act as an ES module replacement for CJS and AMD concepts where exports are a single object. ^[TypeScript: Documentation - Modules] ^[chapter 12 p181 JavaScript Next | SpringerLink] ^[Avoid Export Default - TypeScript Deep Dive] ^[ModuleImport]

The transitional replacement comes with several downfalls, here to name a few.

Additionally, you'll find many considerable high-value resources on why one should avoid default exports:

Hence, enforce explicit named imports by using named exports. Here is the matching eslint rule import/no-default-export.

1.3.0 – Build aspects

Now let's have a look at some of the important build terms, like tree-shaking and code-splitting, which are two techniques to reduce the size of JavaScript bundles in web applications.

1.3.1 – Code splitting

Code-splitting is bundling your code in a way so that it’s grouped into many small bundles that can be loaded as they are needed. This is also sometimes referred to as "lazy loading" and is a feature supported by bundlers.

The best way to introduce code-splitting into your app is through the dynamic import("...").then(module=>{ ... }) expression.^[Code-Splitting – React] ^[What Does a Bundler Actually Do? – INNOQ]

React has for this reason created the lazy() method, aiding in dynamically importing components.

// Instead of
import About from './page/About';

// Lazy load - code splitting
import { lazy } from "react";
const About = lazy(() => import('./About'));

You should read this React guide for best practices regards code splitting.

Enabling code splitting in the bundle process requires some additional configuration, which we will explore later on. ^[Rollup Config for React Component Library With TypeScript + SCSS] ^[Webpack Code Splitting for your Library]

If you initiated a React application with create-react-app or Next.js, then code splitting is enabled by default.

When using Babel, you’ll need to make sure that Babel can parse the dynamic import() expression (React lazy() load) but is not transforming it. For that, you will need the @babel/plugin-syntax-dynamic-import plugin.

1.3.2 – Tree shaking

Tree shaking, also known as dead code elimination, is a mechanism used by a bundler to remove unused code. ^[Reduce JavaScript Payloads with Tree Shaking | Web Fundamentals | Google Developers ]

For example, unused imports are eliminated.

import { add, subtract } from "./mathFuncs";

add(1, 1);

In the above example, subtract is never used and will be removed during the bundle.

Property objects are removed as well. /user.js

export const user {
    name: "penguin28",
    email: "penguin@iceberg.com"
}

/index.js

import { user } from "user";
console.log(user.email); // -> penguin@iceberg.com

In the above example, name from the user object is never used and removed during the bundle.

In general, tree shaking only works with ES modules (with some exceptions for CJS) and the library or application must be side effect free (explained in the next section).

Additionally, most bundlers only allow tree shaking in production environments.

If you're using Babel's babel-preset-env, then Babel will transpile your ES6 modules into more widely compatible CJS modules (from import to require), which is great until we want to start tree shaking. The solution is to leave ES6 modules alone in the Babel configuration. ^[Reduce JavaScript Payloads with Tree Shaking | Web Fundamentals | Google Developers]

{
  "presets": [
    ["env", {
      "modules": false
    }]
  ]
}

The same goes to transpiling your code with Typescript. You have to set "module": "esnext" or "module": "es6" to prevent typescript from replacing your import's with require's. esnext is just a dynamic value indicating the latest ECMAScript version.

1.3.3 – Side Effect

A side effect is not a bundler or JavaScript-specific term. It is a general programming concept about behaviors of functions (and not modules). A function is said to have side effect if it tries to modify anything outside its body (scope). For example, if it modifies a global variable, then it is a side effect. If it makes a network call, it is a side effect as well. A function that contains a side effect is also named an impure function. ^[The Not-So-Scary Guide to Functional Programming | YLD Blog] ^[Chapter 5 React Hooks in Action] ^[Master the JavaScript Interview: What is Functional Programming? | by Eric Elliott | JavaScript Scene | Medium]

  • Pure functions always return the same output, given the same input. It is predictable.
  • "Impure" functions directly mutates variables, state, data outside its body.

Sounds confusing? Stay with me as we go through an example with a pure and impure function, where the latter function modifies state outside its body.

let myValue = 1;

/**
 * ===============
 * === Pure function ===
 * ===============
 */
function pureAdd(a, b) {
    return a + b;
}

// running the pure function several times with the 
// same arguments results in the same output. 
// It is predictable and produces the same output, given the same input
pureAdd(1, 1); // -> 2
pureAdd(1, 1); // -> 2
pureAdd(1, 1); // -> 2

// Same goes for using our variable as an argument. The pure function doesn't 
// directly mutate "myValue"
pureAdd(myValue, 1); // -> 2
pureAdd(myValue, 1); // -> 2
pureAdd(myValue, 1); // -> 2

/**
 * =================
 * === Impure function ===
 * =================
 */
function impureAdd(a) {
    return myValue + b;
}

// running the impure function several times with the 
// same arguments results in the different output. 
// State is mutated outside of the functions scope
impureAdd(1); // -> 2
impureAdd(1); // -> 3
impureAdd(1); // -> 4

// Same goes for using our variable as an argument
myValue = 1; // reset
impureAdd(myValue); // -> 2
impureAdd(myValue); // -> 4
impureAdd(myValue); // -> 8

Here impureAdd() mutates a state, the myValue value, outside its body and thereby creates unpredictable results that could affect applications globally.

To name a few side effects in a JavaScript application:

  • mutate or access a browser API object, like global window or document objects, e.g.
  • importing global CSS import "./scss/main.scss", we need to treat any CSS as potentially having side effects because even CSS modules can define global CSS ^[Chapter 2 - Loaders - CSS in JSModern JavaScript Tools & Skills [Book]]
  • timer functions like setTimeout() or setInterval()
  • Ajax calls. Fetching data can lead to unintended side effects. What if the data is falsy or the fetch fails?

Side effect in a Bundler and React

At a later point, we need to know the two-different interpretations for side effects in React and a bundler.

In a bundler, a side effect means that your file does something other than just exporting functions, classes, etc. A very common example is loading a .css file. Any .css file can potentially alter your entire app, so for the app to work correctly the CSS needs to be loaded even if your app doesn't reference the code in the file that imports the CSS. The bundler needs to know about this so that it can perform tree shaking so that any file that has side effects needs to be included whether it appears to be used or not.

In React, a side effect means that something happens when the component is rendered other than actually updating the DOM. These side effects should not directly happen in a component. It should be in a lifecycle method, which the useEffect() hook is made for. ^[Using the Effect Hook – React] useEffect() is for triggering additional logic after a React component has updated the DOM, such as a data fetch or a subscription.

In short, putting side effects inside in a component's body is not considered as a side effect to a bundler (even if it was inside the useEffect hook). A bundler only recognizes a file having side effects if the side effect is running outside a component body, i.e., not part of component lifecycles. E.g. window.myVariable = 42 in the outermost scope of a Button.js file.

Thus, for a React library, you should only mark CSS files (.css, .sass, … ) as side effects. We will see in at a later point how to mark side effect files.

1.3.4 – Cherry-picking (WIP)

REVIEWER NOTICE THIS SECTION MAY BE WRONG AND NEEDS REWORK.

Cherry-picking reduces the final bundle size of an application by only importing specific parts or components of a library, instead of the whole. This only works:

  • with libraries that export in CJS or EMS
  • when the selected part or component is side effect free.
  • the bundler supports tree shaking

Some libraries like lodash publish standalone ESM libraries lodash-es, beside their main CJS library. ^[Importing modules in JavaScript, are we doing it right? - DEV Community] ^[Minimizing bundle size - MUI] ^[How To Use Correctly JavaScript Utility Libraries]

Modules can be cherry-picked, regardless of a CJS or EMS library using their absolute paths, e.g.

const last = require("lodash/last")  // CJS
import last from "lodash-es/last"; // ESM - but only if module is default exported too

ES6 object destructuring can also be used as an alternative, however, only if the library supports EMS exports.

import { last } from "lodash-es";

As a library author, export your items as close to top-level as possible to have as little friction as possible for the library consumers. ^[TypeScript: Documentation - Modules]

1.3.5 – Dependency types

We differentiate between three types of dependencies.

  • Regular dependencies are dependencies that a library or application needs during the runtime. E.g., a UI or utility library required to render something
  • Dev dependencies are dependencies that a library or application only requires during the development release, like bundler, transpilers, linters,...
  • Peer dependencies are dependencies where both a consuming application and a library depend on. To avoid duplicate installations (as this may exist with regular dependencies), only the consuming application has an installation of said dependency and provides it to the library.

In Node.js, these dependency types are specified in the package.json file with the dependencies, devDependencies and peerDependencies fields.

As by an example, we have a library that contains all three types of dependencies, specified in its package.json file. When an application adds this library, the following happens:

  • dependencies – These dependencies are installed alongside the library. Each dependency is installed in the root node_modules directory if it doesn't already exist. If it does exist, then versions are checked to see if they are compatible. In case of incompatible versions, the same dependency but of another version is installed in the library's directory, in its own node_module directory.
  • devDependencies These dependencies are not installed alongside the library.
  • peerDependencies – These dependencies are not installed alongside the library. The library checks if it can use the application's provided dependency version, or else throws an error.

React is always to be considered as a peer dependency in a React specific library.

2.0.0 – Serving a library

2.1.0 – How does a library work?

Installing a library can be done in several ways, but the most common are:

  • npm/yarn (e.g.,npm install lodash)
  • using a script tag that links to a file or a CDN link (e.g., <script src="https://unpkg.com/browse/lodash@4.17.21/" />)
  • adding the package files manually by extracting them from the source. This is only reasonable in a browser environment.

Whenever a consuming application installs a library with npm install <package-name>, the consuming application looks for the library's package.json file. This file contains the library's usage instructions, specified in fields. The same applies to CDN's, which read the package.json file to know what to output in their CDN links.

The following fields are the important ones to a library author.

Field Description Utilized by Example
main • main entry point of the library
• falls back to the root /index.js (if available)
• Node.js "main":"./dist/index.js"
module • entry point designated for ESM version of a library
• common convention among bundlers like Webpack^[ Authoring Libraries | webpack], Rollup^[pkg.module · rollup/rollup Wiki · GitHub] and esbuild^[esbuild - main fields]
• mainly only used by bundlers to tree shake with help of ES modules features
• ES module aware bundlers like Rollup, Webpack and esbuild "module":"./dist/index.esm.js"
type • specify if .js files are treated like CJS or ES modules
• defaults to "type":"commonjs"
• recommended to always include this field (regardless of default value) to future-proof the library in case Node.js ever changes the default module system ^[Modules: Packages | Node.js v17.3.0 Documentation]
• Node.js "type":"module"
types • entry point for typescript definition files .d.ts ^[ TypeScript: Documentation – Publishing]
typings field is synonymous with types and could be used as an alternative
• falls back to the main field by looking for the same filename with FILENAME.d.ts instead of FILENAME.js, then root /main.d.ts and then to /index.d.ts if available ^[TypeScript: Documentation – Creating .d.ts Files from .js files]
• Typescript "types":"./dist/index.d.ts"
files • array of files or directories to include in the published library (e.g., publishing a library to a package registry like www.npmjs.com)
• Acts like a whitelist compared to .gitignore (or .npmignore which is not recommended – discussed later)
• even if not specified, some files are always included (e.g., package.json) ^[package.json | npm Docs]
• if not specified, defaults to include all files except a some^[package.json | npm Docs] are included
• Node.js "files":"["lib"]"
exports • Dual module packages configuration (only applies to ES and CJS modules supported by Node.js)
• specify the ES and CJS module the entry points of the library
• Node.js can now either use CJS or ESM version of the library, instead only the CJS version specified in main while the ESM version in module was only ever used by bundlers
• more details in the next section as this requires further understanding
• Node.js
• Bundlers (only implemented by a few)
• Typescript (work in progress)
"exports":[ 
   "import":"./dist/index.esm.js"
   "require":"./lib/index.js"
]
unpkg • entry point for the UNPKG CDN^[UNPKG]
• only supports UMD
• falls back to the root /umd field and then to main field if not specified and fallbacks are exists
• ignores browser field
• UNPKG CDN "unpkg":"./lib/index.umd.js"
jsdelivr • entrypoint for the jsDelivr CDN^[GitHub - jsdelivr/jsdelivr: A free, fast, and reliable Open Source CDN for npm, GitHub, JavaScript, and ESM]
• only supports UMD (ESM support in the development ^[jsDelivr ESM])
• falls back to browser field and then to main field
• jsDelivr "jsdelivr":"./lib/index.umd.js"

In addition, there are popular development websites like CodePen and CodeSandbox allowing you to write code in the browser and include npm packages. CodePen depends on the module field ^[Skypack + CodePen How packages are included] while Codesandbox prefers the exports and module fields. ^[How does Codesandbox consume libraries? · Discussion #6369 · codesandbox/codesandbox-client · GitHub]

How to add a library to UNPKG or jsDelivr? As soon as you publish a library to npm, the library is automatically added to both CDN's too. No further action is required from the library author.

Except main, browser, files and exports, all fields are widely accepted community convention fields.

2.0.1 – Multi module libraries

In a library's package.json file, the main field instructs Node.js how to include the library in an application. In the past, this sufficed since Node.js was built on a single module system, CJS.

With the introduction of ES modules, there were now two module systems. Interoperating between them and transitioning to ESM turned out to be problematic ^[https://2ality.com/2019/04/nodejs-esm-impl.html#interoperability]. With the main field, library authors had to decide whether to output CJS or ES modules. As a result, it became a common pattern for library authors to build to both CJS and ES modules in a package, where main pointed to a CJS entry point and module to an ESM entrypoint ^[https://2ality.com/2019/10/hybrid-npm-packages.html#legacy-approach-for-putting-es-modules-on-npm]. The module field, serving ESM, is only used by bundlers and other build tools, since Node.js ignored (and still ignores) said field ^[Modules: Packages | Node.js v17.3.0 Documentation]. This allowed for best backwards compatibility for older Node.js versions, while using bundlers tree shaking advantages with the ESM entry point.

Today, since Node.js v13.7.0, a library can now contain both CJS and ES module entry points at the same time.

Nearest parent package.json The nearest parent package.json is defined as the first package.json found when searching in the current folder, that folder’s parent, and so on up until a node_modules/ folder or the volume root is reached. ^[Modules: Packages | Node.js v17.3.0 Documentation]

The exports field

The exports field provides an alternative to the main field, while also being able to specify separate entry points for CJS and ESM files. main is overridden by exports if it exists.

The exports field either accepts a single entry point, acting like the main field, or accepts an object of multiple subpaths and/or pre-defined conditions to construct several entry points ^[https://2ality.com/2019/10/hybrid-npm-packages.html#option-3%3A-bare-import-esm%2C-deep-import-commonjs-with-backward-compatibility]. Conditions provide a way to use different entry points depending on certain conditions, also known as conditional exports.

Some conditions are:

The order of the conditions in the object matters. Therefore, default should always come last. Additionally, all conditions in the above lists are Node.js conditions, types which is a typescript-specific condition ignored by Node.js.

Example of a single entry point.

{
  "main": "./lib/index.js",
  "exports": "./lib/index.js",
}

Example of conditional exports, i.e., separate entry points for CJS and ESM files.

{
  "main": "./lib/index.js",
  "exports": {
      "." : {  // "." = from root directory
          "import": "./lib/index.esm.js",
          "require": "./lib/index.js",
          "default": "./lib/index.js",
      }
  }
}

Furthermore, exports comes with two additional features:

A more complete example of exports with encapsulation.

{
  "main": "./lib/index.js",
  "exports": {
    "./package.json": "./package.json",
    ".": {
        "import": "./lib/index.esm.js",
        "require": "./lib/index.js",
    },
    "./utils": {
        "import": "./lib/utils.esm.js",
        "require": "./lib/utils.js",
    },
    "./grid": {
        "import": "./lib/grid/index.esm.js",
        "require": "./lib/grid/index.js",
    }
  }
}

Now, only the defined subpaths can be imported

import { myLib } from "myLib"; // Works and resolves to "./lib/index.esm.js"
import { utils } from "myLib/utils"; // Works and resolves to "./lib/utils.esm.js"
const grid = require("myLib/grid").grid // Works and resolves to "./lib/grid/index.js"

import { superSecret } from "myLib/secret" // Throws ERR_PACKAGE_PATH_NOT_EXPORTED
const superSecret = require("myLib/secret").superSecret // Throws ERR_PACKAGE_PATH_NOT_EXPORTED

import { superSecret } from "./node_modules/myLib/src/secret.js" // Works and resolves "./src/secret.js"

The reason the last example worked is that an absolute specifier (i.e., the file path), instead of the bare specifier (i.e., the library name) bypasses the encapsulation. ^[https://nodejs.org/api/packages.html#main-entry-point-export]

It is recommended to keep main, module and types field as fallbacks for older Node.js versions, bundlers, and typescript definitions which not yet fully support the exports field.

Dual package hazards

When an application is using a package that provides both CJS and ESM module sources, there is a risk of certain bugs if both versions of the package get loaded.

This potential comes from the fact that the package instance created by const pkgInstance = require('pkg') is not the same as the package instance created by import pkgInstance from "pkg" (or an alternative main path like "pkg/module")

While it is unlikely that an application or package would intentionally load both versions directly, it is common for an application to load one version while a dependency of the application loads the other version. This hazard can happen because Node.js supports intermixing CommonJS and ES modules, and can lead to unexpected behavior.

-- Node.js Dual package hazard

2.1.2 – What to expose?

There is no single correct answer as it depends on compatibility and performance. We will deal later on with the implementations.

A new React library, should output in its package.json

{
  "name": "my-lib",
  
  // Treat ".js" files as CJS
  "type": "commonjs",
  
  "exports": {
      ".": {
          // ESM entrypoint when using "import" statement or "import()" expression in modern Node.js
          "import": "./dist/index.esm.js",
          
          //  CJS entrypoint when using "require()" function in modern Node.js
          "require": "./dist/index.cjs.js"
      }
  },
  
  // CJS fallback for older Node.js version
  "main": "./dist/index.cjs.js",
  
  // Fallback for build tools that do not yet support "exports"
  "module": "./dist/index.esm.js",
  
  // Fallback for typescript versions not supporting "exports"
  "types": "./dist/index.d.ts",
  
  // Serve UMD bundle for browsers
  "browser": "./dist/index.umd.js",
  
  // Serve UMD bundle for UNPKG CDN (which ignores "browser" field)
  "unpkg": "./dist/index.umd.js",
  
  // Serve UMD bundle for jsDelivr CDN 
  "jsdelivr": "./dist/index.umd.js",
  
  // Only include "dist/" and the default files, i.e. "package.json", "README", "LICENCE" and the file
  // in the "main" field in the published library.
  "files": [
      "dist"
  ]
  
  // Mark library side effect free to allow tree-shaking
  // In case using CSS, mark CSS files as NOT side effect free
  "sideEffects": false
}

You will find many package.json variations on the web for libraries. These may differ in prioritizing ESM over CJS ^[https://nodejs.org/api/packages.html#dual-commonjses-module-packages], entirely ignore a UMD bundle for CDNs or use multiple package.json's to create a more optimized setup. ^[https://2ality.com/2019/10/hybrid-npm-packages.html#option-4%3A-bare-import-esm%2C-deep-import-commonjs-with-.mjs-and-.cjs]

These variations are, just like ours, slightly opinionated and should be carefully selected, i.e., you should understand the choices made to do X over Y.

In the long run, above's package.json is a solid base, favoring CJS over ESM in times of transitioning (lasting years to come) and thereby providing better support for all environments.

We refrained from using .cjs and .mjs file extensions since Browser support is vague. E.g. Both Chrome ^[JavaScript modules · V8] and Firefox^[Aside — .mjs versus .js] ^[MJS Push request for Firefox] support the .mjs file extension, but not much is known for Safari, Opera,...

Some additional good reads:

Inspecting libraries

Libraries usually host their source code on a code repository website like GitHub. However, only the source code, not the build codes, reside there.

The most straightforward way to inspect the built files is by looking through the built on UNPKG As an example, here are the published www.npm.com files of react-bootstrap.

3.0.0 – Building the library

Now that we have all the basic knowledge and understand the different factors, let's get our hands dirty.

3.0.1 – Setting up the demo library

The next sections will be based on a demo React library, that outputs a single <Button> component. The library will use typescript, which should be expected from library authors in 2021. We will add SCSS Modules and images later on.

The demo library is built with yarn as the package manager, which is recommended, but you may also choose npm if that suits you better.

Structure

In the next steps, my-lib/ is replaced with / shortening code snippets.

my-lib/ # <- root directory
├── .gitignore
├── README.md
├── package.json
└── src/ # <- src directory with the our code
      ├── components/
      │     ├── Button/
      │     │     ├── Button.tsx 
      │     │     └── index.ts # <- exports the Button.tsx
      │     └── index.ts # <- exports all components from the components/ directory
      └── index.ts # <- exports the library

The package.json file

Initialize the package.json file with

yarn init -y
# Or for npm:
npm init -y

yarn and npm create two different package.json's. The following package.json is the result of the yarn init -y command.

{
  "name": "my-lib",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
}

Dependencies

Add the dependencies to our sample.

yarn add --dev typescript react react-dom lodash-es @types/react @types/react-dom
# Or for npm:
npm install --save-dev typescript react react-dom lodash-es @types/react @types/react-dom

For now, we add all dependencies as devDependencies. We figure out later if we move some to regular or peer dependencies. Lodash is not a requirement for a React library and only serves as a demonstration of a handling additional packages in our library.

The tsconfig.json file

To complete our demo library, we create the bare minimum React tsconfig.json file to satisfy Typescript and our IDE.

{
  "compilerOptions": {
    // Tells TypeScript to explicitly ignore ".js" files
    "allowJs": false,
    // Inform Typescript and the IDE that this is a React project
    "jsx": "react",
    // Enable interoperability helper between CJS and ES modules
    // React library yields CJS modules which we import with the "import" statement
    "esModuleInterop": true,
    // Specify the file lookup resolution algorithm when importing
    // We must use the Node.js algorithm
    "moduleResolution": "node"
  }
}

File contents

And populate the files with the following code.

/src/components/Button/Button.tsx

import React from "react";
import { last } from "lodash-es"; // ESM version of lodash

export type ButtonProps = {
  /**
   * Button's display text
   *  ( This description structure is useful if you use a documentation
   *  tool like Storybook, Docz, ... )
   */
  text: string;
};

export function Button(props: ButtonProps) {
  const lastVal = last([1, 2, 3]);
  return <button>{props.text} - {lastVal}</button>;
}

The last method from lodashextracts the final value of an array. We're using that value in our button text.

/src/components/Button/index.ts

export { Button } from "./Button"

/src/components/index.ts

export { Button } from "./Button"

/src/index.ts

export * from "./components"

Default exports?

Wait, where are the default exports?

Experienced developers might wonder why we didn't use default exports, i.e., export { default as Button } from "./Button". We've already discussed this topic in "Interoperability, named and default Exports", but in short, named exports are the right way.

3.1.0 – Bundling and Transpiling

There are plenty of different ways to transpile or bundle our library. First, let's address some important topics.

3.1.1 – Is a bundler needed?

A bundler goes hand in hand with a transpiler, but a transpiler does not depend on a bundler.

If your library consists only of some .tsx and .ts files (or .jsx/.js), then using a bundler might be over the top. Here, a transpiler suffices to convert your files to plain .js files.

However, if your library uses other resources like stylesheets .css, images, …, then things get more complicated ^[typescript - React Component Library - Is a bundler needed? - Stack Overflow]. A transpiler's main objective is to convert modern JavaScript into backwards compatible JavaScript. ^[Babel (transcompiler) - Wikipedia]. In fact, some transpilers can handle other resources with the help of additional plugins, just like bundlers which require plugins as well. Yet, a transpiler remains limited in its functionalities, and in many cases requires you to write remaining processing.

For example:

Therefore, as a rule of thumb, use a bundler for all output formats, until you know that a transpiler suits you better, with strong emphasis on the "until you know".

3.1.2 – Tools

Transpilers

Out of the many transpilers on the market, probably the most popular ones are Babel and TypeScript's tsc tool. The main differences between them are:

That being so, Babel is a powerful, feature-rich and versatile transpiler, that also supports typescript. The lack of type-checkings makes Babel obviously run faster, and modern IDE's like VSCode have built in type-checks ^[TypeScript Compiling with Visual Studio Code] thanks to tsserver

Both tsc and tsserver share an internal library which does the work on type checking, i.e., both yield the same results in context of type checking. -- Discord response from Orta, one of the Typescript maintainers

For our demo library, we're going to use a combination of Babel and tsc. Babel handles all transpilation while tsc creates the type definition .d.ts files.

Choosing between Babel or tsc can be summarized with ^[TypeScript: Documentation - Using Babel with TypeScript]:

  • Is your build output mostly the same as your source input files? Use tsc
  • Do you need a build pipeline with multiple potential outputs? Use Babel for transpiling and tsc for type checking & definitions

Bundlers

As for bundlers, Webpack, rollup.js, esbuild - API and Parcel are the popular ones.

All four bundlers will likely get the job done, but some are better suited for a specific job than others.

Webpack is the most common react applications bundler and used by create-react-app. It's frequently said that webpack is not a great choice for libraries ^[Webpack and Rollup: the same but different | by Rich Harris | webpack | Medium] ^[comment by a webpack maintainer)] and one should rather use Rollup instead – which still holds true.

Vite.js mentions they won't use esbuild anytime soon due to its beta status, and esbuild has no UMD support. On a personal side, I have used parcel a couple of years ago and didn't like the "zero config" idea.

Either way, I haven't used esbuild or Parcel to provide valuable feedback.

3.2.0 – The build

Remember: / denotes my-lib/.

3.2.1 – Step 1 – Update the tsconfig.json

For our demo library, the only purpose of typescript is to yield the typescript definitions .d.ts ^[TypeScript: Documentation - Creating .d.ts Files from .js files].

We therefore update the tsconfig.json file with several new properties.

/tsconfig.json

{
  "compilerOptions": {
    // Tells TypeScript to explicitly ignore ".js" files
    "allowJs": false,
    // Inform typescript that this is a react project
    "jsx": "react",
    // Enable interoperability helper between ESM and CJS modules
    // React library yields CJS modules which we import with the "import" statement
    "esModuleInterop": true,
    // Specify the file lookup resolution algorithm when importing
    // We must use the Node.js algorithm
    "moduleResolution": "node",

    
    // ======== NEW ========
    
    // Types should go into this directory.
    // Removing this would place the .d.ts files next to the .js files
    "outDir": "dist/types",
    // Generate d.ts files
    "declaration": true,
    // This compiler run should only output d.ts files
    "emitDeclarationOnly": true,
    // Create sourcemaps for d.ts files.
    // go to ".js" file when using IDE functions like
    // "Go to Definition" in VSCode
    "declarationMap": true,
    // Skip type checking all ".d.ts" files.
    "skipLibCheck": true,
    // Ensure that Babel can safely transpile files in the TypeScript project
    "isolatedModules": true
  },
  // Include the following directories
  "include": ["src"],
  // Optional, exclude some patterns from typescript
  "exclude": [
    "**/__tests__",
    "**/__mocks__",
    "**/__snapshots__",
    "**/*.test.*",
    "**/*.spec.*",
    "**/*.mock.*"
  ]
}

You could now run a test with CD'ing into the library directory and run

npx tsc

The above command created the /dist/types/index.d.ts file along all connected sub files, paths, and definition maps. It used by default the tsconfig.json configuration file.

Since we're using include to specify the compiler source directory, we don't have to exclude node_modules which exists in a directory above /src. Only test files should be excluded here.

3.2.2 – Step 2 – Transpile with Babel

To begin with, install all the required babel dependencies as dev dependencies.

yarn add --dev @babel/core @babel/cli @babel/preset-env @babel/preset-react @babel/preset-typescript 
# Or with npm:
npm install --save-dev @babel/core @babel/cli @babel/preset-env @babel/preset-react @babel/preset-typescript 

Where:

With that, we can now create a basic Babel config file. /.babelrc

/**
 * Note: presets Order DOES matter, 
 * reads from bottom to top: https://stackoverflow.com/a/39798873/3673659
 * 
 * And yes, comments are allowed in .babelrc JSON files
 */
{
  "presets": [
    ["@babel/preset-env", { "modules": false }],
    [
      "@babel/preset-react",
      {
        // Use the modern JSX runtime technique with "automatic"
        // This removes the need to import react in each file
        // Read more: https://reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html#whats-a-jsx-transform
        "runtime": "automatic"
      }
    ],
    "@babel/preset-typescript"
  ]
}

@babel/preset-env transpiles to a JavaScript version specified by a browser target list. These targets are powered by browserslist and the default aims at supporting > 0.5%, last 2 versions, Firefox ESR, not dead which suffices for our case.

Additionally, did you notice that we're using module.export = { ... } here and not the ES module version export = { ... }? Remember that our goal package.json, we set "module":"commonjs" meaning that our .js files are treated as CJS modules. We write React files in ESM since we have a following transpilation process. But the babel config itself has no transpiler, meaning it's treated as a CJS module.

Now, let's do a test run.

npx babel src --extensions .ts,.tsx --out-dir "dist/cjs"

Where:

  • src – the source file or directory
  • --extensions .ts,.tsx – required only for @babel/cli to handle typescript files ^[@babel/preset-typescript · Babel]
  • --out-dir "dist/cjs"– transpile to the /dist/cjs/ directory

And you should see now that Babel transpiled all .tsx and .ts files to .js files inside the /dist/cjs/ directory.

If your package uses dynamic import() expressions, or React lazy() loading method, then you must add @babel/plugin-syntax-dynamic-import plugin to your babel config.

Extra: "modules":"false"

You might remember the reason for using "modules":"false"(explained further up). Since we're using rollup, this step is optional. Rollup's Babel plugin automatically sets "modules":"false" in newer versions. ^[plugins/packages/babel at master · rollup/plugins · GitHub]

3.2.3 – Step 3 – Bundle with Rollup

Install all the required Rollup dependencies as dev dependencies:

Dependencies

npm

yarn add --dev rollup @rollup/plugin-babel @rollup/plugin-node-resolve @rollup/plugin-commonjs
# Or with npm:
npm install --save-dev rollup @rollup/plugin-babel @rollup/plugin-node-resolve @rollup/plugin-commonjs

Where:

Configuration

Create a base Rollup configuration file. /rollup.config.js

import { babel } from "@rollup/plugin-babel";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";

const extensions = [".js", ".jsx", ".ts", ".tsx", ".css"];

export default [
  // CJS and ESM
  {
    input: "src/index.ts",
    output: [
      {
        file: "./dist/index.cjs.js",
        format: "cjs",
        sourcemap: true,
      },
      {
        file: "./dist/index.esm.js",
        format: "esm",
        sourcemap: true,
      },
    ],
    plugins: [
      // Helper to locate "node_modules" modules
      nodeResolve({
        // Only activate the plugin on files from the extensions list
        extensions,
      }),
      // Helper to convert CJS modules to ESM
      commonjs({
        // Only run the helper on legacy node_modules dependencies that use CJS
        include: ["node_modules/**"],
      }),
      babel({
        babelHelpers: "bundled",
        include: ["src/**/*"],
        exclude: ["node_modules/**"], // required; else Babel transpiles "node_modules" modules aswell since we have imports of them in the files
        extensions,
      }),
    ],
  },
  // UMD
  {
    input: "src/index.ts",
    output: [
      {
        file: "./dist/index.umd.js",
        format: "umd",
        sourcemap: true,
        // UMD requires a bundle name used to expose the library in the global scope
        name: "myLib",
      },
    ],
    plugins: [
      // Helper to locate node_modules modules
      nodeResolve({
        // Only activate the plugin on files from the extensions list
        extensions,
      }),
      // Helper to convert CJS modules to ESM
      commonjs({
        // Only run the helper on legacy node_modules dependencies that use CJS
        include: ["node_modules/**"],
      }),
      babel({
        babelHelpers: "bundled",
        include: ["src/**/*"],
        exclude: ["node_modules/**"], // required; else Babel transpiles "node_modules" modules aswell since we have imports of them in the files
        extensions,
      }),
    ],
  },
];

Babel relies on very small helper functions during the transpilation. By default, these functions are added to every file that requires it, leading to possible duplications. However, Rollup can be aware of duplications and thereby bundle said helper functions only once. Therefore, you can specify several values for babelHelpers:

Use bundled until you know you need runtime.

babelHelpers ISSUE runtime vs bundled: [babel-plugin] - babelHelpers, what is the difference between runtime and bundled? · Issue #1076 · rollup/plugins · GitHub

Furthermore, did you notice that we used the import statement instead of the require function and export default instead of module.export = { ... }, compared to the Babel configuration file? That is because Rollup by default expects config files to be ESM! If you want to use CJS, then you have to change the file extension of the config to .cjs. ^[rollup.js]

Fixing dependency types

As you might remember, there are three dependency field types in the package.json, regular dependencies, devDependencies and peerDependencies fields.

As for now, all our dependencies are specified as devDependencies – meaning none of them is installed alongside our library.

React being a peer dependency, we expect the consuming application to provide the React installation.

Lodash, on the other hand, is different. Do we expect the consuming application to have lodash installed, just like React? Not really. Do we need lodash to run the library? Definitely. Therefore, we have to move Lodash to the regular dependencies, ensuring it's added alongside the library.

If, two different libraries (A and B) depend on the same library C, and the first library (A) requires exactly version 2.4.1 of library C, while the other library (B) depends on 2.4.5, then both version of library C are installed in the respective library's node_modules directory, e.g., node_modules/lib-a/node_modules/lib-c and node_modules/lib-b/node_modules/lib-c.

With that in mind, we update our package.json.

{
  "name": "my-lib",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "devDependencies": {
    "@babel/cli": "^7.16.0",
    "@babel/core": "^7.16.5",
    "@babel/preset-env": "^7.16.5",
    "@babel/preset-react": "^7.16.5",
    "@babel/preset-typescript": "^7.16.5",
    "@rollup/plugin-babel": "^5.3.0",
    "@rollup/plugin-commonjs": "^21.0.1",
    "@rollup/plugin-node-resolve": "^13.1.1",
    "@types/react": "^17.0.38",
    "@types/react-dom": "^17.0.11",
    
    // "lodash-es": "^4.17.21", <-- lodash removed
    
    // Keep react and react-dom in devDependencies to 
    // ensure they are installed after a cloning
    // the library and running npm install
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    
    "rollup": "^2.62.0",
    "typescript": "^4.5.4"
  },
  
  // NEW
  "dependencies": {
    "lodash-es": "^4.17.21" // <-- lodash moved here
  },
  
  // NEW
  "peerDependencies": {
    "react": ">=16.8.0", 
    "react-dom": ">=16.8.0"
  }
}

Having lodash version, "^4.17.21", means that our library supports all lodash versions from 4.17.21 (included) to 5.0.0 (excluded). You can also install 4.0.0 and see if that works in your project, thereby satisfying all lodash v4 installations. With regular dependencies, we want to be flexible with minor or patch versions only, and not major where we jump from v2 to v3.

In Semver, the version syntax is [major, minor, patch] where major is considered breaking changes. E.g. 4.31.12: 4- major, 31 – minor and 12 is the patch number.

External and Globals

Let's test our Rollup config file from the previous step with

npx rollup -c

Here, -c is the shortened flag of --config <filename> telling Rollup to use a config file, where only the latter may have an <filename> input. If no filename is specified, the command expects by default the rollup.config.js filename.

The above command yields the following files in the /my-lib/dist directory.

my-lib/ 
└── dist/
    ├── index.cjs.js  # <- 121 kB
    ├── index.cjs.js.map  # <- 227 kB
    ├── index.esm.js  # <- 121 kB
    ├── index.esm.js.map  # <- 227 kB
    ├── index.umd.js  # <- 124 kB
    └── index.umd.js.map  # <- 227 kB

Do we see right? File sizes of 120 kB and above for a single <Button> component?

Yes, that's right, and that is because our final bundles include all of React and the last method of lodash.

We can exclude libraries from being bundled by specifying them in the external array option in Rollup. UMD and IIFE bundles need additionally the globals option, telling Rollup that these external, not bundled, dependencies exist in the global scope (e.g., window.react).

Updating the config. /rollup.config.js

import { babel } from "@rollup/plugin-babel";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";

const extensions = [".js", ".jsx", ".ts", ".tsx", ".css"];

export default [
  // CJS and ESM
  {
    input: "src/index.ts",
    output: [
      {
        file: "./dist/index.cjs.js",
        format: "cjs",
        sourcemap: true,
      },
      {
        file: "./dist/index.esm.js",
        format: "esm",
        sourcemap: true,
      },
    ],
    plugins: [
      // ...
    ],
    
    // NEW
    external: ["react", "react-dom", "lodash-es"] 
  },
  // UMD
  {
    input: "src/index.ts",
    output: [
      {
        file: "./dist/index.umd.js",
        format: "umd",
        sourcemap: true,
        name: "myLib",
        
        // NEW
        globals: {
          react: "React",
          "react-dom": "ReactDom",
        }
      },
    ],
    plugins: [
      // ...
    ],
    
    // NEW
    // Without lodash-es, more 
    // about that in the next section
    external: ["react", "react-dom"]
  },
];

Running Rollup again.

npx rollup -c

The new bundled files now have the expected sizes.

my-lib/ 
└── dist/
    ├── index.cjs.js  # <- 414 bytes
    ├── index.cjs.js.map  # <- 702 bytes
    ├── index.esm.js  # <- 299 bytes
    ├── index.esm.js.map  # <- 698 bytes
    ├── index.umd.js  # <- 1,2 kB
    └── index.umd.js.map  # <- 1,4 kB

To bundle or not to bundle dependencies

Why did we not specify lodash-es in the external array for the UMD bundle? Because, we actually want lodash-es to be part of our UMD bundle.

Our CJS and ESM bundles are targeted for Node.js environments, whereas the UMD bundle is intended to be directly included in browsers with CDN links and <script> tags. In any case, peer dependencies should never be bundled.

In Node.js, all dependencies are managed with npm/yarn and package.json, meaning we don't have to include any regular dependencies in our bundles (and never should).

In Browsers, on the other hand, where we use CDN links and <script> tags, we don't have the luxury of package managers. We rely entirely on already included libraries, or included libraries that bundle their dependencies. Package management in the browser is left to the consumer. Here, the library author must decide what they consider as peer dependencies and regular dependencies, and only include regular dependencies in the final bundle. For example:

  • The most popular React component library, Material UI has included all dependencies, except the peer dependency react and react-dom leading to a bloated, unminified, file size of 1.29MB
  • Until Bootstrap 5, jQuery and popper were considered as peer dependencies. Users first had to include both dependencies before they could use Bootstrap in the browser.

In essence, in browser targeted bundles (UMD, IIFE or browser dedicated ESM), include the regular dependencies in your bundles.

Following this, we can slightly improve our Rollup config to read the regular dependencies and peerDependencies from our package.json, creating only one source of truth.

/rollup.config.js

import { babel } from "@rollup/plugin-babel";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";

// NEW
import pkg from "./package.json";

const extensions = [".js", ".jsx", ".ts", ".tsx", ".css"];

export default [
  // CJS and ESM
  {
    input: "src/index.ts",
    output: [
      {
        file: "./dist/index.cjs.js",
        format: "cjs",
        sourcemap: true,
      },
      {
        file: "./dist/index.esm.js",
        format: "esm",
        sourcemap: true,
      },
    ],
    plugins: [
      // ...
    ],
    
    // Don't bundle regular and peer dependencies
    external: [
      ...Object.keys(pkg.dependencies || {}), // <-- UPDATED
      ...Object.keys(pkg.peerDependencies || {}) // <-- UPDATED
    ] 
  },
  // UMD
  {
    input: "src/index.ts",
    output: [
      {
        file: "./dist/index.umd.js",
        format: "umd",
        sourcemap: true,
        name: "myLib",
        globals: {
          react: "React",
          "react-dom": "ReactDom",
        }
      },
    ],
    plugins: [
      // ...
    ],
    
    // Don't bundle peer dependencies
    external: [
      ...Object.keys(pkg.peerDependencies || {}) // <-- UPDATED
    ] 
  },
];

3.2.4 – Step 4 – npm-scripts

Given that we have now the means to generate all our bundle formats (CJS, ESM and UMD) and Typescript definition, let's go ahead and automate these processes with npm-scripts in our package.json.

For this, we need additional dependencies.

yarn add --dev rimraf npm-run-all
# Or with npm:
npm install --save-dev rimraf npm-run-all

Where:

  • rimraf – Cross platform (Windows, Mac, Linux) rm -rf (remove) Linux alternative. Rollup or Typescript does not clean up the /dist directory themselves.
  • npm-run-all – Cross-platform tool to run multiple npm-scripts parallel or sequential.

Then update the package.json.

{
  "name": "my-lib",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  
  // NEW
  "scripts":{
    "build": "npm-run-all --sequential 'build:clean' 'build:types' 'build:bundles'", // run one after another
    "build:clean": "rimraf dist", // clean up the dist directory
    "build:types": "tsc", // create type definitions
    "build:bundles": "rollup -c" // create bundles
  }
  
  "devDependencies": {/*...*/},
  "dependencies": {/*...*/},
  "peerDependencies": {/*...*/},
}

3.2.5 – Step 5 – Expose the bundles

At the end, we expose all our bundles in the package.json.

{
  "name": "my-lib",
  "version": "1.0.0",
  "license": "MIT",
  // "main": "index.js", // <-- Removed from this line here and re-added a bit further down below
  "scripts": {/*...*/},
  "devDependencies": {/*...*/},
  "dependencies": {/*...*/},
  "peerDependencies": {/*...*/},
  
  // NEW 
  
  // Treat ".js" files as CJS
  "type": "commonjs",

  "exports": {
      ".": {
          // ESM entrypoint when using "import" statement or "import()" function in modern Node.js
          "import": "./dist/index.esm.js",
          
          //  CJS entrypoint when using "require()" function in modern Node.js
          "require": "./dist/index.cjs.js"
      }
  },
  // CJS fallback for older Node.js version
  "main": "./dist/index.cjs.js",
  
  // Fallback for build tools that do not yet support "exports"
  "module": "./dist/index.esm.js",
  
  // Fallback for typescript versions not supporting "exports"
  "types": "./dist/index.d.ts",
  
  // Serve UMD bundle for browsers
  "browser": "./dist/index.umd.js",
  
  // Serve UMD bundle for UNPKG CDN (which ignores "browser" field)
  "unpkg": "./dist/index.umd.js",
  
  // Serve UMD bundle for jsDelivr CDN 
  "jsdelivr": "./dist/index.umd.js",
  
  // Only include "dist/" and the default files, i.e. "package.json", "README", "LICENCE" and the file
  // in the "main" field in the published library.
  "files": [
      "dist"
  ],
  
  // Mark library side effect free to allow tree-shaking
  // In case using CSS, mark CSS files as NOT side effect free
  "sideEffects": false
}

We achieved our final package.json goal.

3.3.0 – Additional Steps

We've seen the basic setup and configuration of our library build. In addition, there are many additional steps that can be added to handle other kinds of resources.

3.3.1 – Images

Handling images in a library is always a tricky part. How do we guarantee that image imports are correctly resolved when an application consumes our library?

A popular solution to this question is converting the image to Base64, and inline it in the HTML. This completely removes the need for import resolution. However, this leads to a 33% increase in disk size. Therefore, as a general advice, keep the amount and sizes of images in a library to a minimum and limit yourself mainly to SVG images.

Handling and converting images can be done thanks to the @rollup/plugin-image plugin. The plugin handles JPG, PNG, GIF, SVG and WebP files.

yarn add --dev @rollup/plugin-image
# Or with npm:
npm install --save-dev @rollup/plugin-image

/rollup.config.js

import image from '@rollup/plugin-image';

export default {
  // ...
  plugins: [
    // ...,
    image()
  ]
};

3.3.2 – Styling

CSS, SASS, CSS Modules, … stylesheet support is done with the rollup-plugin-postcss plugin, using PostCSS under the hood.

rollup-plugin-postcss is the first unofficial Rollup plugin we introduce. It's maintained by many contributors and over 300.000 weekly downloads on npmjs.

yarn add --dev rollup-plugin-postcss postcss
# Or with npm:
npm install --save-dev rollup-plugin-postcss postcss

/rollup.config.js

import postcss from 'rollup-plugin-postcss'

export default {
  // ...
  plugins: [
    // ...,
    postcss()
  ]
};

Since we're now using CSS files in our project, we have to mark them as side effects in our package.json.

{
  "name": "my-lib",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  /*...*/

  // Mark CSS files as side effects
  "sideEffects": [
      "*.css",
      "*.scss", // SASS
      "*.less", // LESS
  ]
}

SASS/LESS

For SASS/LESS support, install Dart Sass.

yarn add --dev sass
# Or with npm:
npm install --save-dev sass

The plugin supports both Dart Sass and node-sass. Dart Sass is prefered over node-sass in case both dependencies exist in a project. Node-sass is deperecated. -- Issue #321, Pull Request #402

That's it, simply installing dart sass unlocked SASS/LESS support in rollup-plugin-postcss. You can now start using the .scss or .less files.

CSS Modules

/rollup.config.js

import postcss from 'rollup-plugin-postcss'

export default {
  // ...
  plugins: [
    // ...,
    postcss({
      modules: true
    })
  ]
};

3.3.3 – Optimization

All our bundles can be further improved, saving bandwidth and reducing the bundle size of the final consuming application.

We can minify our bundles using the rollup-plugin-terser plugin. The plugin uses terser, a JavaScript compressor toolkit, under the hood.

yarn add --dev rollup-plugin-terser
# Or with npm
npm install --save-dev rollup-plugin-terser

/rollup.config.js

import postcss from 'rollup-plugin-postcss'

export default {
  // ...
  plugins: [
    // ...,
    terser(),
  ]
};

4.0.0 – Development Environment

Developing a library differs a little to the way we're used to.

4.0.1 – Local Development

We haven't checked the <Button> component a single time, to see if it's actually working. Therefore, we have to somehow render it in a browser.

There are many development approaches, with each their advantages and disadvantages.

Demo application

A straightforward solution is to install a demo create-react-app application, putting it in a /demo folder in the root directory. Then, simply include the components by their paths, e.g., import { Button } from "./../src/components/Button".

The demo application renders the components, but can also serve as a playground for non UI items like Hooks, utility methods, … .

One major downside is, that you have to set up the demo environment yourself.

Component Development Tool

Component development tools like Storybook, Docz or Styleguidist can replace demo applications. These tools provide a canvas, props and code playground, allowing you to experiment with each component in an isolated sandbox. Additionally, they serve as the documentation for your library.

Storybook offers the closest demo application experience with all its features allowing you to quickly modify props and state. It may be the best choice, but comes at a price of a steeper learning curve and more time-consuming setup, compared to Styleguidist and Docz.

For the inexperienced, a Storybook should be the preferred tool.

Include in real application

You can also include the library from very the beginning in a real application. This approach might be only suited for dev-teams building in-house libraries.

A common process is to use npm link or yarn link, to create symlinks between an application and a library directory. While this is a valid solution, a developer should know about the npm link or yarn link obstacles in a React setup, explained in this article.

A better process would be to use yalc, a tool mimicking npm locally and thereby avoiding the React linking issues from the previous mentioned article. On a note, after you added a library to a project with yalc, and said library has regular dependencies, then you must run npm install/yarn install in the project directory once to install the regular dependencies too. yalc does not install libraries in node_modules, but in its on version, the .yalc directory.


Now the library is ready to be submitted to www.npm.js or whatever package repository you end up choosing.

About

A Node.js/React library build guide, with complete explanation of each decision taking.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published