Contents

Creating a React Component Library using TypeScript, Rollup and Storybook

Problem Overview

It happens to everybody, at one time or another: You find yourself (or your teammates) copying source code from an existing app and pasting into a new one.

It could be a simple helper function, or some default configuration, or even a full set of components. The thing is: You’re duplicating code to solve the same problem, again and again, growing your hard-to-maintain codebase.

Tired of this, you decide to extract that code to a nice library. More precisely, a npm package. But how? Which tools should you use? Which best practices must you follow? Let’s find out.

 

Yet another tutorial about this?

There are plenty “How to build a lib with React” tutorials online. Some are really good, to be honest. But I haven’t found one with the exact same tech stack and project configuration that we are going to use here.

Also, many articles rely on tools like Create React App or Create React Library to bootstrap the library. Although I am a big fan of Create React App for building React Web Apps, it isn’t really suited for creating libs. And I have not tried Create React Library for real projects, but it looks a bit opinionated and I don’t feel comfortable with it.

So, we are going to get our hands dirty, building our lib from scratch. It is a nice opportunity to learn some cool new stuff and the end result will look exactly as we want to.

I’m not saying that this is (or isn’t) the best way to do it, but I’ll try to explain all of my choices.

 

What are we going to build?

If you are reading this post, we can assume that you are:

  • Facing a situation like the one described above.
  • Whishing to go beyond building “typical web apps” only.
  • Working or playing with React, of course.

That said, we are going to build a React Component Library, a natural choice, because it is a very common use case and it forces us to go through all the steps below.

Anyway, if you want to, you could follow most of the steps to build any JavaScript library that you need.

 

Hands-on

“Talk is cheap. Show me the code." (Linus Torvalds)

Let’s build our lib! In the end of the day, the project’s structure will look like this.

 

Initial Setup

Start creating the project’s folder (may be differ, if you’re using Windows):

mkdir react-component-lib && cd react-component-lib

 

Then, init it with NPM:

npm init -y

 

Add ESLint config, by creating .eslintrc.json:

{
  "env": {
    "browser": true,
    "es2021": true,
    "jest": true,
    "node": true
  },
  "settings": {
    "react": {
      "version": "detect"
    }
  },
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:@typescript-eslint/recommended"
  ],
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaFeatures": {
      "jsx": true
    },
    "ecmaVersion": 12,
    "sourceType": "module"
  },
  "plugins": ["react", "@typescript-eslint", "react-hooks"],
  "rules": {
    "react/jsx-uses-react": "off",
    "react/react-in-jsx-scope": "off",
    "react/prop-types": "off",
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn",
    "@typescript-eslint/explicit-module-boundary-types": "off"
  }
}

 

Our node module is ready to go. Feel free to customize .eslintrc.json with any rules, plugins and presets that you want to apply.

 

TypeScript

We are going to use TypeScript to write our components. The main reasons are:

  • It adds static typing, resulting in a more scalable and less prone to erros code base.
  • Library users will get better IntelliSense via type definitions.
  • No need for writing and maintaining separate type definitions, for people using the package within TypeScript projects.

However, if you want to use pure JavaScript yourself, just skip these TS steps and you’ll be fine.

 

Let’s begin with TypeScript installation, as a development dependency:

npm install --save-dev typescript

 

Then, add TS config, by creating tsconfig.json. Again, feel free to add your own tsconfig.json options:

{
  "compilerOptions": {
    "target": "es6",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "build"]
}

 

That’s it. Pretty simple, don’t you think?

 

React

Because we are not using any boilerplate, let’s setup React ourselves.

Start adding React as a development dependency:

npm install --save-dev react react-dom @types/react

 

We are building a library, not an app, and it is important to declare it within package.json as a peer dependency, as well.

Whoever uses our lib, must include React as a dependency:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// package.json
{
  "name": "react-component-lib",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "peerDependencies": {
    "react": ">=17.0.2",
    "react-dom": ">=17.0.2"
  },
  "devDependencies": {
    "@types/react": "^17.0.11",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "typescript": "^4.3.4"
  }
}

Notice the >= prefix for both dependencies (lines 14-15). It says “people must meet at least this version”.

 

Finally, let’s add the first component, by creating src/FirstComponent/index.tsx:

export type FirstComponentProps = {
  title?: string;
  body?: string;
};

export const FirstComponent = (props: FirstComponentProps) => {
  const {
    title = "Hello, World!",
    body = "I am the first component of this lib",
  } = props;

  return (
    <div>
      <h1>{title}</h1>
      <p>{body}</p>
    </div>
  );
};

 

We’re done with React and our first component. To develop your library, just add more components to the src directory.

 

Storybook

Storybook is an open source tool for building UI components and pages in isolation. It streamlines UI development, testing, and documentation.” (from the official website)

I really like using Storybook on my projects. It provides a beautiful and efficient way of documenting and showcasing React components. It also has an easy setup and rich/extensive docs.

 

So let’s install Storybook to the project:

npx sb init

 

We won’t need the stories directory. So, remove it.

Add your first story, by creating src/FirstComponent/stories.tsx:

import { Story, Meta } from "@storybook/react";

import { FirstComponent, FirstComponentProps } from ".";

export default {
  title: "FirstComponent",
  component: FirstComponent,
} as Meta;

export const Default: Story<FirstComponentProps> = (args) => (
  <FirstComponent {...args} />
);

 

Modify .storybook/main.js, to detect any story files containing the stories suffix:

1
2
3
4
module.exports = {
  stories: ["../src/**/*stories.@(js|jsx|ts|tsx)"],
  addons: ["@storybook/addon-links", "@storybook/addon-essentials"],
};

 

Run Storybook locally:

npm run storybook

Your component should be rendering, at last. Play with “Controls”, to test props changing.

 

Jest, RTL and Babel

For the tests setup, there is no need for talking about alternatives. Jest and React Testing Library are the default choices for any React project in 2021. The only decision to make is about how to handle TypeScript.

Using ts-jest is a fair option, because it adds support to type-checking and all other TypeScript features. If you want to know why some people would take this way, go to this page.

We’ll be fine without it, though, creating a simple Babel setup, for the sake of performance.

 

Start adding Jest, React Testing Library and related type packages:

npm install --save-dev jest @types/jest @testing-library/react @testing-library/jest-dom

 

Then, add Jest configuration. Create jest.config.js and .jest/setup.ts:

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/.jest/setup.ts'],
  testPathIgnorePatterns: ['<rootDir>/lib/', '<rootDir>/node_modules/'],
  roots: ['./src'],
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*stories.tsx',
    '!src/index.ts',
    '!src/types/*',
  ],
}
// .jest/setup.ts
import '@testing-library/jest-dom'

 

As you can see, @testing-library/jest-dom was imported inside .jest/setup.ts, so it could affect all tests by default. This file is referenced by Jest’s global config file, jest.config.js.

After that, add test, test:watch and coverage scripts to package.json:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  // package.json
  // ... 

  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "coverage": "jest --coverage",
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook"
  },

  ...
}

 

Just a few more steps, before creating our tests: We need to use Babel in our project, so Jest could handle .ts and .tsx files (with JSX syntax, by the way). To do so, add the following development dependencies:

npm install --save-dev @babel/preset-env @babel/preset-react @babel/preset-typescript

 

Those presets bring all plugins and config options for working with React and TypeScript here. You will also find that @babel/core was already added by Storybook.

Let’s now use them, creating .babelrc.json file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "env": {
    "test": {
      "presets": [
        "@babel/preset-env",
        "@babel/preset-typescript",
        ["@babel/preset-react", { "runtime": "automatic" }]
      ],
      "plugins": []
    }
  }
}

We must not forget telling Babel to parse React’s new JSX Transform. We did that, by setting the option { "runtime": "automatic" } (line 7).

 

Finally, we can create src/FirstComponent/test.tsx:

import { render } from "@testing-library/react";
import { FirstComponent } from ".";

describe("<FirstComponent />", () => {
  it("should render default title and body", () => {
    const { getByRole, getByText, container } = render(<FirstComponent />);

    getByRole("heading", { name: /hello, world!/i });
    getByText(/i am the first component of this lib/i);

    expect(container.firstChild).toMatchSnapshot();
  });

  it("should render title and body passed as props", () => {
    const { getByRole, getByText } = render(
      <FirstComponent title="Custom Title" body="Custom Body" />
    );

    getByRole("heading", { name: /custom title/i });
    getByText(/custom body/i);
  });
});

 

And now run the tests:

npm test # alias for 'npm run test'

Run in watch mode:

npm run test:watch

Run code coverage analysis:

npm run coverage

 

Well, we had to put some more effort here. But now we’ve got our tests setup with Jest and RTL, TypeScript support and the first render and snapshot tests are already written!

 

Rollup

Last, but not least, the bundling config.

There are some good contenders here. Webpack, Rollup, ESBuild, Vite, Snowpack…

Narrowing our options to Rollup and Webpack, two of the most used JavaScript module bundlers, the former was the choice.

This article tells more about the pros and cons, and this Stack Overflow post brings more updated opinions to the discussion.

TL;DR: Use Webpack for apps and Rollup for libraries. So, let’s finish it!

Add rollup.js and plugins as development dependencies:

npm install --save-dev rollup rollup-plugin-peer-deps-external rollup-plugin-postcss rollup-plugin-terser @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-typescript tslib

 

Add rollup config, by creating rollup.config.js:

import commonjs from '@rollup/plugin-commonjs'
import resolve from '@rollup/plugin-node-resolve'
import typescript from '@rollup/plugin-typescript'
import peerDepsExternal from 'rollup-plugin-peer-deps-external'
import postcss from 'rollup-plugin-postcss'
import { terser } from 'rollup-plugin-terser'

import packageJson from './package.json'

const plugins = [peerDepsExternal(), resolve(), commonjs(), postcss(), terser()]
const exclude = ['node_modules', 'lib', 'src/**/*stories.tsx', 'src/**/*test.*']
const tsConfig = { declaration: true, declarationDir: './lib', rootDir: 'src/', exclude }

export default [
  // CommonJS
  {
    input: 'src/index.ts',
    output: { dir: './', entryFileNames: packageJson.main, format: 'cjs' },
    plugins: [...plugins, typescript(tsConfig)],
  },

  // ES
  {
    input: 'src/index.ts',
    output: { file: packageJson.module, format: 'esm' },
    plugins: [...plugins, typescript({ exclude })],
  },
]

 

Create an entry file for rollup, src/index.ts. You must export all your public components here:

export * from './FirstComponent'

 

Add lib info and build script to package.json:

{
  "name": "react-lib-boilerplate",
  "version": "1.0.0",
  "main": "lib/index.js",
  "module": "lib/index.esm.js",
  "files": [
    "lib"
  ],
  "types": "lib/index.d.ts",

  // ...

  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "coverage": "jest --coverage",
    "build": "rollup -c",
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook"
  },

  // ...
}

 

Finally, run the build script:

npm run build

 

Congratulations, your library is ready to be shared! The code was bundled inside the lib directory, including type declarations!

 

Publishing to NPM

Now we are ready do use and share our library. Of course, you could use a private and self hosted npm registry (your company’s, for instance). But we will use the public npm registry, because it’s easy and ready to go.

First of all, make sure that your lib has an unique name (search npm for existing libraries with the same name). Also, set the initial version to 1.0.0.

Then, add a prepublishOnly script to package.json, to make sure that rollup will build the last version of the code to the lib directory, before every publishing.

{
// package.json
  "name": "my-lib-unique-name",
  "version": "1.0.0",

  // ...

  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "coverage": "jest --coverage",
    "build": "rollup -c",
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook",
    "prepublishOnly": "npm run build"
  },

  // ...

}

 

Next, login to NPM:

npm login

 

Finally, publish your library:

npm publish

 

Everything under the directories informed in files section (package.json) will be sent to the NPM registry.

Now, to use your lib as a dependency, just npm install my-lib-unique-name. For updates, all you have to do is to increment the version (using Semantic Versioning) and repeat the publish steps.

 

Conclusion

I know, it was a very long post. But, if you’re still here, it means that you’re now able to build and publish your own React Component Libraries, including embedded components showcase, tests setup, TypeScript support and more. Not bad, don’t you think?

I thought about adding even more steps, like a component boilerplate generation with Plop. But I guess it was beyond our scope and I didn’t want to mix things too much. Maybe I’ll consider it for the next post.

If you feel that something important is missing, and if you want to, please be my guest to open an issue in my GitHub repository, so we can talk about it. Also, if you liked this post, please share it with your friends and give the repo a star :)