How to create a React components ESM+CJS library
Coder Spirit

How to create a React components ESM+CJS library

NOTE: This article is focused on the technical aspects of package creation and publishing, and assumes that you already know how to create React components. If you are not familiar with React, I recommend reading the official documentation first.

Introduction

Creating (and publishing) good, small and easy to use libraries is a bit more complicated than just writing nice, modular and reusable code. Each language and ecosystem has its own particularities, in our case we’ll focus on the JavaScript and TypeScript ecosystems, putting special emphasis on frontend development.

The following points are usually relevant when creating & publishing a JavaScript/TypeScript library:

  • There are different module systems (ESM, CommonJS, IIFE, UMD, etc.) that are not compatible with each other. Supporting all of them is not always necessary, but it’s a good idea to support at least ESM and CommonJS; doing so is not always trivial.
  • We might have to consider different environments (Browser(s), Node, Deno, Bun, etc.).
  • In the case of JavaScript, we might have to target older versions of the language (to ensure compatibility with older browsers or engines).
  • In the case of TypeScript, we should compile our code before publishing it (to ensure that it can be consumed by pure JavaScript projects).
  • We might want to make our bundles as small as possible, as well as tree-shakeable.
  • As the cherry on top, we might also want to provide type definitions to maintain interoperability with TypeScript, and source maps to make debugging easier.

With all this in mind, let’s see how we can create a React component library that fulfills all these requirements 😄.

Project Setup

NOTE: Although I’ve done my best to describe all the relevant details in this article, it might be useful for you as a reader to explore some real code in case you find holes in the explanation that follows.

The Package Manager

For the rest of this article, I’ll assume that we are using pnpm as our package manager of choice. If you are using npm or yarn, then you’ll have to adapt the commands accordingly. In any case, I recommend using pnpm over the others.

To create a new project, we can run the following commands:

mkdir beautiful-tree # or whatever name you want to give to your project
cd beautiful-tree
pnpm init # This will create a package.json file. We'll edit it later.

Dependencies

We want to install react and react-dom as peer dependencies (and not as “normal” dependencies), if you are not sure why, then I recommend reading the article from the previous link.

pnpm add --save-peer react react-dom

The rest of the dependencies are development dependencies, so we’ll install them as such.

  • We’ll use typescript to type-check our code
  • react and react-dom are necessary for our tests to work, while @types/react and @types/react-dom are necessary for our type checks.
  • rollup is the module bundler that we’ll use. @rollup/plugin-terser is a plugin that we’ll use to minify our code. @rollup/plugin-typescript is a plugin that we’ll use to transpile our TypeScript code, and rollup-plugin-dts will help us to emit type definitions. tslib is also necessary as a peer dependency for @rollup/plugin-typescript.
  • We’ll use vitest as our test runner, together with @testing-library/react to test our React components, and jsdom to provide a browser-like environment for our tests.
  • publint will help us validate that our package.json file is correct (specifically the exports and types fields)
pnpm add --save-dev \
     jsdom \
     publint \
     react \
     react-dom \
     rollup \
     @rollup/plugin-terser \
     @rollup/plugin-typescript \
     @testing-library/react \
     @types/react \
     @types/react-dom \
     rollup-plugin-dts \
     tslib \
     typescript \
     vitest

When it comes to “normal” dependencies, we need them to be either dual ESM/CJS packages, or at least ESM packages. In our case, we won’t be installing any, but it’s important to keep that in mind if we want to meet our requirements.

TypeScript

Although we can run pnpm tsc --init to create a new tsconfig.json file, for this example we’ll create it manually, so we can explain each relevant option in detail.

NOTE: From now on, you will find that the code snippets contain comments with insights that would take too much time to explain in the main text. I recommend reading them.

Some of them will be inside JSON files, so you’ll have to strip them out before using the code.

{
  "compilerOptions": {
    // To ensure that our code is compatible with "slightly old" browsers,
    // we target an older version of ECMAScript (ES2020 in this case).
    // See https://www.typescriptlang.org/tsconfig#target
    "target": "es2020",

    // We load these 3 libraries in the global scope so TypeScript knows
    // about the DOM and ES2020 features.
    // See https://www.typescriptlang.org/tsconfig#lib
    "lib": ["ES2020", "DOM", "DOM.Iterable"],

    // To have compatibility with ES modules, we can use the values:
    // - ES2015: Very basic support for ES modules
    // - ES2020: Supports dynamic imports and import.meta
    // - ES2022: Supports top-level await
    // See https://www.typescriptlang.org/tsconfig#module
    "module": "ES2020",

    // The 'bundler' resolution strategy is similar to the 'node16' and
    // 'nodenext' strategies (in that it supports package.json "exports" and
    // "imports" fields), but it allows us to not have to specify the file
    // extension when importing files (which is nice, because we'll be bundling
    // everything anyway, so the extensions are not relevant).
    // See https://www.typescriptlang.org/tsconfig#moduleResolution
    "moduleResolution": "Bundler",

    // This tells TypeScript to use the `react-jsx` factory function when
    // transpiling JSX syntax.
    "jsx": "react-jsx",

    // Our code will be placed in the ./src directory
    "baseUrl": "./src",

    // We'll use Rollup to emit code, instead of tsc.
    "noEmit": true,

    // Most bundlers have limitations when dealing with features such as
    // `const enum` (which can affect code generation across different files).
    // Because of this, it is a good idea to ensure that every module is
    // compilable on its own, without relying on other modules.
    // See https://www.typescriptlang.org/tsconfig#isolatedModules
    "isolatedModules": true,

    // I'm surprised that this option is still not enabled by default, because
    // it's basically a bug fix for a mistake they made in their past
    // assumptions on how ES modules work.
    // See https://www.typescriptlang.org/tsconfig#esModuleInterop
    "esModuleInterop": true,

    // Feel free to not use these options if you don't want to, but my
    // suggestion is to always use it, so you can catch more errors at compile
    // time.
    "strict": true,
    "checkJs": true,
  },
  // In a real project, we might need to add some more directories to the
  // "exclude" array, but for this example we'll keep it simple.
  "exclude": [
    "dist/**/*",
    "node_modules/**/*",
  ]
}

Rollup

As we mentioned before, we’ll use Rollup as our module bundler, as it combines flexibility with ease of use, and it allows us to generate different types of bundles.

Let’s create a rollup.config.mjs file at the root of our repository (adapt the referred files to your needs):

import { defineConfig } from 'rollup'

// We'll use this plugin to generate the .d.ts files
import dts from 'rollup-plugin-dts'

// We'll use this plugin to transpile our TypeScript code
import pluginTs from '@rollup/plugin-typescript'

// We'll use this plugin to minify our code
import terser from '@rollup/plugin-terser'

// Some constants that we'll use later
const input = 'src/main.ts'
const external = ['react', 'react-dom', 'react/jsx-runtime']
const globals = {
  react: 'React',
  'react-dom': 'ReactDOM',
  'react/jsx-runtime': 'jsxRuntime',
}

export default defineConfig([
  {
    input,
    output: [
      // We tell Rollup to generate a CommonJS bundle
      {
        // The .cjs extension is not strictly necessary, but it helps
        file: 'dist/beautiful-tree.cjs',
        format: 'cjs',
        globals, // We tell Rollup how to map the external dependencies
        sourcemap: true, // We want sourcemaps for debugging purposes
      },
      // We tell Rollup to generate an ESM bundle
      {
        // The .mjs extension is not strictly necessary, but it helps
        file: 'dist/beautiful-tree.mjs',
        format: 'es',
        globals, // We tell Rollup how to map the external dependencies
        sourcemap: true, // We want sourcemaps for debugging purposes
      },
      // We tell Rollup to generate an IIFE bundle
      // (for browser environments)
      {
        name: 'BeautifulTree', // The global name given to the "bundle"
        file: 'dist/beautiful-tree.iife.js',
        format: 'iife',
        globals, // We tell Rollup how to map the external dependencies
        sourcemap: true, // We want sourcemaps for debugging purposes
      },
      // We tell Rollup to generate an UMD bundle
      // (as a sort of "universal" default)
      {
        name: 'BeautifulTree', // The global name given to the "bundle"
        file: 'dist/beautiful-tree.umd.js',
        format: 'umd',
        globals, // We tell Rollup how to map the external dependencies
        sourcemap: true, // We want sourcemaps for debugging purposes
      },
    ],
    // We tell Rollup to treat the following dependencies as external
    // (see the "external" constant above)
    external,
    plugins: [pluginTs()],
  },
  // We have another set of output files that uses a slightly different
  // configuration: We want to minify the code, so we use `terser` as a plugin,
  // and add the `min` infix to the output filenames.
  {
    input,
    output: [
      {
        file: 'dist/beautiful-tree.min.cjs',
        format: 'cjs',
        globals,
        sourcemap: true,
      },
      {
        file: 'dist/beautiful-tree.min.mjs',
        format: 'es',
        globals,
        sourcemap: true,
      },
      {
        name: 'BeautifulTree',
        file: 'dist/beautiful-tree.min.iife.js',
        format: 'iife',
        globals,
        sourcemap: true,
      },
      {
        name: 'BeautifulTree',
        file: 'dist/beautiful-tree.min.umd.js',
        format: 'umd',
        globals,
        sourcemap: true,
      },
    ],
    external,
    plugins: [pluginTs(), terser()],
  },
  // We also want to generate type definitions for our library:
  {
    input,
    output: [
      // Both files will be equal, but we need to generate them separately
      // to deal with some edge cases related to how TypeScript loads types.
      // An interesting thread on the topic (sadly, in Xitter):
      // https://twitter.com/AndaristRake/status/1695549037556949344
      { format: 'cjs', file: 'dist/beautiful-tree.d.cts' },
      { format: 'es', file: 'dist/beautiful-tree.d.mts' },
    ],
    external,
    plugins: [dts()],
  },
])

You probably noticed that we are generating .cjs and .mjs files, when in theory using the .js extension should be enough if our package.json exports are correctly configured (if you don’t know about that, don’t worry, we’ll get to it in a few lines). The reason for that is plain and simple defensive programming; I’ve seen too many weird bugs caused by tooling that does not properly “understand” the exports field in package.json.

Creating a dummy React component

Now that we have both TypeScript and Rollup configured, we can create a dummy React component to test that everything is working as expected.

Let’s create a file called BeautifulTree.tsx inside the ./src directory:

export type BeautifulTreeProps = {
  id: string
  width: number
  height: number
}

export function BeautifulTree({ id, width, height }: BeautifulTreeProps): JSX.Element {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      id={id}
      viewBox={`0 0 ${width} ${height}`}
      style={{
        width: `${width}px`,
        height: `${height}px`,
      }}
      className={'beautiful-tree-react'}
    >
    {/* replace with real code to draw a tree */}
    <circle
      cx={width * 0.5}
      cy={height * 0.5}
      r={Math.min(width, height) * 0.5}
    />
    </svg>
  )
}

and a main.ts file inside the ./src directory:

// It is a good practice to separate type imports/exports from
// value imports/exports
export type { BeautifulTreeProps } from './BeautifulTree'
export { BeautifulTree } from './BeautifulTree'

Building our dummy React component

Now, if we run the command

pnpm rollup --config rollup.config.mjs

we should see that a bunch of files have been generated inside the ./dist directory (type definitions, source maps, and minified/non-minified versions of different bundles).

Visualizing our dummy React component

To visualize our dummy React component, we’ll use Storybook. I won’t get into too much details, because Storybook itself guides us through the process of creating a new project with an interactive tutorial.

# Install Storybook and initialize its configuration
pnpm dlx storybook@latest init

When it asks us about the builder for the project, choose Vite, which is much faster than the other proposed alternative (at the time being, “Webpack 5”).

We can close the Storybook process by typing Ctrl+C in the terminal, and start it again with the command:

pnpm run storybook

Now that we have Storybook installed, we can create a new “story” where we can visualize our component.

Let’s create a file called BeautifulTree.stories.tsx inside the ./src/stories directory:

import { BeautifulTree } from '../BeautifulTree'
import type { Meta, StoryObj } from '@storybook/react'

// To learn more about this, check the official Storybook documentation.
const meta = {
  title: 'BeautifulTree',
  component: BeautifulTree,
  parameters: { layout: 'centered' },
  tags: ['autodocs'],
  argTypes: {},
} satisfies Meta<typeof BeautifulTree>

export default meta

type Story = StoryObj<typeof BeautifulTree>

export const MyFirstStory: Story = {
  args: {
    id: 'my-first-story',
    width: 200,
    height: 200,
  },
}

If we now go to the Storybook UI, we should see that we have a new story called “MyFirstStory”, and if we click on it we should see our dummy React component.

We could certainly live without Storybook, but it’s a nice tool to verify visually that our components are working as expected, while also serves as a sort of interactive documentation.

Vitest

Once we have our component, and we have verified that it works as expected, we can prepare tests based on this knowledge. We can also prepare unit tests for internal details that are not tied to the UI, but those should be simpler to implement and don’t need much explanation.

For our tests, we’ll use Vitest, which is a lightweight test runner with native support for ESM, TypeScript and JSX, and happens to be much faster than Jest.

Our configuration file is vitest.config.mts:

// eslint-disable-next-line import/no-unresolved
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    // We'll use the `jsdom` environment to run tests that depend on the
    // browser DOM
    environment: 'jsdom',
  },
})

We’ll create an example tests file called BeautifulTree.test.tsx inside the ./src/tests directory:

import { BeautifulTree } from '../BeautifulTree'
import { describe, expect, it } from 'vitest'
import { render } from '@testing-library/react'

describe('BeautifulTree', () => {
  it('renders a boring circle', () => {
    const rendered = render(
      <BeautifulTree
        id="my-boring-test-tree"
        width={200}
        height={200}
      />
    )

    // NOTE: Snapshots are like black boxes, and depending too much on them for
    //       testing can make our tests brittle. Use them sparingly.
    //
    // The first time that we run this test, it will generate a snapshot that
    // will be used to compare against future runs. We trust that the first
    // execution is correct because we saw it in Storybook before.
    expect(rendered).toMatchSnapshot()
  })
})

To run our tests, we can execute the following command:

pnpm vitest run

The package.json File

After we have our components ready, we can start thinking about what do we have to write in our package.json file to ensure that our library is compatible with different module systems and environments.

{
  "name": "@beautiful-tree/react",
  "version": "0.1.1",
  "private": false,
  // `main` is the legacy way of defining the CJS entry point of the library.
  // We keep it for backwards compatibility.
  "main": "./dist/beautiful-tree.cjs",
  // `module` is the legacy way of defining the ESM entry point of the library.
  // We keep it for backwards compatibility.
  "module": "./dist/beautiful-tree.mjs",
  // top-level `types` is the legacy way of telling how to load the type
  // definitions. We keep it for backwards compatibility.
  "types": "./dist/beautiful-tree.d.cts",
  // `exports` is the modern way of defining the entry points of the library.
  "exports": {
    // Because we have minified and non-minified versions of our code, we add
    // an extra level of nesting to the `exports` object (usually not needed).
    // This one is for the path "@beautiful-tree/react";`
    ".": {
      // We need an extra level of nesting for our "import" key because it
      // needs to have its own independent "types" key.
      "import": {
				// the `types` entry must be always the first one.
        "types": "./dist/beautiful-tree.d.mts",
        "default": "./dist/beautiful-tree.mjs" // ESM entry point
      },
      "require": {
        "types": "./dist/beautiful-tree.d.cts",
        "default": "./dist/beautiful-tree.cjs" // CJS entry point
      },
      // We don't bother on adding types for the browser and default
      // entries, because we don't use these modules during development.
      "browser": "./dist/beautiful-tree.iife.js", // browser entry point
      "default": "./dist/beautiful-tree.umd.js" // default entry point
    },
    // This one is for the path "@beautiful-tree/react/min";`
    "./min": {
      "import": {
        "types": "./dist/beautiful-tree.d.mts",
        "default": "./dist/beautiful-tree.min.mjs"
      },
      "require": {
        "types": "./dist/beautiful-tree.d.cts",
        "default": "./dist/beautiful-tree.min.cjs"
      },
      "browser": "./dist/beautiful-tree.min.iife.js",
      "default": "./dist/beautiful-tree.min.umd.js"
    }
  },
  // We use the `files` allow-list to ensure that only the generated bundles
  // are published to npm.
  "files": ["dist"],
  "scripts": {
    "build": "rollup --config rollup.config.mjs",
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "devDependencies": {
    // ...
  },
  "peerDependencies": {
    // ...
  }
}

Validating our package

Publint

A very nice tool that we can use to validate that our package.json file declares type definitions and entry points correctly is publint, which also has its own online checker.

pnpm publint --strict # I recommend using the --strict option

AreTheTypesWrong

Another tool that we can use to validate that our package properly exports type definitions is the website Are The Types Wrong?.

We can also use its CLI version, although I don’t recommend adding it to our CI pipeline, because it’s difficult to exclude some irrelevant problems with enough precision (we would exclude too much, or too little).

# To install it
pnpm add --save-dev @arethetypeswrong/cli

# To run it
pnpm attw --pack

Conclusion

Thank you for reading until the end! 😄 I hope that this article has been useful/interesting to you. If you liked it, it would mean a lot to me if you could share it with your friends and colleagues.

I usually replicate many of the articles from this blog in Dev.to to increase their reach. If you want to support me, you can give a “like” to the article in Dev.to 🙏🏼💖.