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
andreact-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, androllup-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, andjsdom
to provide a browser-like environment for our tests. publint
will help us validate that ourpackage.json
file is correct (specifically theexports
andtypes
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 🙏🏼💖.