Creating a React component library using Storybook 6

Last updated on by Prateek Surana   •   - min read

If you have multiple projects that are using the same design system (inputs, buttons, other reusable components, etc.), then you probably have a good enough use case to create a shared component library that can be published and consumed directly by all your projects.

Another benefit is that you can develop UI components easily in isolation and render their different states directly, without needing to mess with the business logic in your dev stack, with the help of Storybook.

Storybook working

In this tutorial, I would be covering the steps for creating and publishing a React component library (Storybook supports countless other frontend frameworks), with the following steps -

  1. Setting up the project
  2. Installing Storybook
  3. Adding stories and setting up the file structure
  4. Compiling the Library using Rollup
  5. Publishing and consuming the library

Setting up the project

Since we are building a component library that would be published to a package manager like NPM, we would be better off if we setup React from scratch instead of using something like create-react-app, which is better suited for web applications.

If you have a component library using React already setup, then you can directly move forward to the next step. We just need a basic React setup before we can install Storybook.

For that, create a new folder with whatever name you want for your component library. I would be calling mine my-awesome-component-library.

Then run yarn init and git init, respectively, in that folder providing appropriate values for the fields asked. This would initialize an empty NPM project with git. Also, set up a gitignore file.

We are building a React component library, so we would need to React to build our components. Also, we are going to use TypeScript to build our library. Let's add that too.

yarn add --dev react react-dom @types/react typescript

Since react requires that we need to have a single copy of react-dom, we will be adding it as a peerDependency so that our package always uses the installing client's version. Add the following snippet to your package.json.

...
"peerDependencies": {
"react": "^16.8.0",
"react-dom": "^16.8.0"
},
...

As one last step for setting up the project, let's also add a tsconfig for compiling our TypeScript. Create a file called tsconfig.json in the root and add the following to it.

{
"compilerOptions": {
"target": "es5",
"outDir": "lib",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"declaration": true,
"declarationDir": "lib",
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
},
"include": [
"src"
],
"exclude": [
"node_modules",
"lib"
]
}

These options help TypeScript to ignore and enforce certain rules while compiling our code. You can check out all the flags available in the docs.

GitHub: Code till this step

Installing Storybook

Now that we have the React boilerplate ready we can now install Storybook, run the following command in the root folder to add Storybook to your project

npx sb init

This command will install all the core devDependencies, add scripts, setup some configuration files, and create example stories for you to get you up and running with Storybook. At the time of writing this article, I am using Storybook version 6.1.9

You can now run yarn storybook and that should boot up Storybook for you with the examples they created for you.

Once you are done playing with the example, you can go ahead and safely delete the stories folder.

Now open the .storybook/main.js file. This file controls the behavior of your Storybook server by specifying the configuration for your stories.

Update the stories key in the file to this -

...
"stories": [
"../src/**/*.stories.tsx"
],
...

This config would run TypeScript stories defined in the src folder, which we would be creating in the next step.

GitHub: Code till this step

Adding stories and setting up the file structure

Now that we have the Storybook setup, we can start creating our components and writing stories for them.

But first of all what are stories anyways?

Glad you asked, from the docs -

"A story captures the rendered state of a UI component. Developers write multiple stories per component that describe all the “interesting” states a component can support."

In short, Stories let you render the different states of your UI component and lets you play with the different states with something called Storybook Controls, which we will get to in a minute. These are development only files and hence won't be included in our final library bundle.

Let's create a demo component to check out how stories work and how you can make the most out of it.

Our file structure would look something like this -

.storybook/
main.js
preview.js
.gitignore
package.json
rollup.config.js
tsconfig.json
src/
components/
MyAwesomeComponent/
MyAwesomeComponent.tsx
MyAwesomeComponent.css
MyAwesomeComponent.stories.tsx
index.ts
index.ts

We will be using the same button component that Storybook gave us with the demo earlier for demonstrating.

Create a folder src/components/Button and paste the Button.tsx, button.css, and index.ts files in it.

Lets add some stories ✨

Create src/components/Button/Button.stories.tsx

Now add the following default export to it -

import React from "react";
import { Meta } from "@storybook/react/types-6-0";
import Button, { ButtonProps } from "./Button";

export default {
title: "Components/Button",
component: Button,
} as Meta;

The default export in a story defines the meta information that will be used by Storybook and its addons.

To define a Story you need to create named exports in the file, so for example we can create a story for the primary button type like this.

export const PrimaryButton = () => <Button label="Hello world" primary />;

To simplify writing multiple stories, Storybook provides an option to create stories by defining a master template and reusing that template for each story. So in our case, the stories for Primary and Secondary type buttons can be created like this -

import React from "react";
import { Meta } from "@storybook/react/types-6-0";
import { Story } from "@storybook/react";
import { Button, ButtonProps } from "./Button";

export default {
title: "Components/Button",
component: Button,
} as Meta;

// Create a master template for mapping args to render the Button component
const Template: Story<ButtonProps> = (args) => <Button {...args} />;

// Reuse that template for creating different stories
export const Primary = Template.bind({});
Primary.args = { label: "Primary 😃", size: "large" };

export const Secondary = Template.bind({});
Secondary.args = { ...Primary.args, primary: false, label: "Secondary 😇" };

If you haven't already, you can restart the Storybook server by rerunning yarn storybook, and you should see the following.

Initial Story Setup

Notice that Storybook automatically generated the controls, according to the component props, for us. This is thanks to react-docgen-typescript, which is used by Storybook to infer the argTypes for a component. One more reason to use TypeScript.

Apart from using auto-generated controls, you can also define custom controls for some or all props using the argTypes key. For example, let's define a custom color picker for the backgroundColor prop, replace the default export in the stories file with this -

export default {
title: "Components/Button",
component: Button,
argTypes: {
backgroundColor: { control: 'color' },
},
} as Meta;

The current story preview also looks a bit weird with the button in one corner of the preview. As one last step, add the layout: 'centered' key to the .storybook/preview.js file to center the preview. This file lets you control how your story is rendered in the Storybook.

If you followed the above steps, your final story preview would look something like this -

Final story setup

GitHub: Code till this step

Compiling the Library using Rollup

Now that you know how to build components with Storybook, it's time to move to the next step, which is compiling our library so that our end applications can consume it.

If you're not familiar with Rollup and wondering why we are using it to compile our library instead of something like webpack, that's because Rollup is best suited for bundling libraries, whereas webpack is suited for apps.

First, we would need to create an entry file that would export all the components for our component library. Create src/index.ts, and since our component library only has one component right now, it would look something like this -

import Button from "./components/Button";

export { Button };

Let's add rollup, run the following to install Rollup and its plugins that we'll be using to bundle the library -

yarn add --dev rollup rollup-plugin-typescript2 @rollup/plugin-commonjs @rollup/plugin-node-resolve rollup-plugin-peer-deps-external rollup-plugin-postcss postcss

Now before we add the rollup config, there are a few types of JavaScript modules that you should be aware of -

  • CommonJS - This module format is most commonly used with Node using the require function. Even though we are publishing a React module (which will be consumed by an application generally written in ESM format, then bundled and compiled by tools like webpack), we need to consider that it might also be used within a Server side rendering environment, which generally uses Node and hence might require a CJS counterpart of the library (ESM modules are supported in Node environment as of v10 behind an experimental flag).
  • ESM - This is the modern module format that we normally use in our React applications in which modules are defined using a variety of import and export statements. The main benefit of shipping ES modules is that it makes your library tree-shakable. This is supported by tools like Rollup and webpack 2+.
  • UMD - This module format is not as popular these days. It is required when the user requires our module using a script tag.

So we would want to support both ESM and CommonJS modules for our component library so that all kinds of support tools can use it in the end application that relies on either of the module types.

To do that, package.json allows adding the entry points for both ESM and CommonJS modules via the module and main key, respectively. So add the following to keys to your package.json -

{ 
...
"main": "lib/index.js",
"module": "lib/index.esm.js",
"types": "lib/index.d.ts",
...
}

The types key would point to the static types generated for your library via Rollup, which would help with IntelliSense in code editors like VSCode.

Its time to add the Rollup config file now, create a file called rollup.config.js in the root folder and add the following to it -

import peerDepsExternal from "rollup-plugin-peer-deps-external";
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import typescript from "rollup-plugin-typescript2";
import postcss from "rollup-plugin-postcss";

const packageJson = require("./package.json");

export default {
input: "src/index.ts",
output: [
{
file: packageJson.main,
format: "cjs",
sourcemap: true
},
{
file: packageJson.module,
format: "esm",
sourcemap: true
}
],
plugins: [
peerDepsExternal(),
resolve(),
commonjs(),
typescript({ useTsconfigDeclarationDir: true }),
postcss({
extensions: ['.css']
})
]
};

Let's break it down one by one to figure out what's happening here.

To start with, the input key indicates the entry point for Rollup for our component library, which is the index.js file that we just created, which contains the exports for all our components.

The output key indicates what types of output files will be generated at which place. As mentioned previously, we would be building the ESM and CommonJS bundles, and we read the output files for both bundles from the package.json.

Lastly there is the plugin array with which we are using the following plugins -

  • rollup-plugin-peer-deps-external - This plugin avoids us from bundling the peerDependencies (react and react-dom in our case) in the final bundle as these will be provided by our consumer application.
  • @rollup/plugin-node-resolve - This plugin includes the third-party external dependencies into our final bundle (we don't have any dependencies for this tutorial, but you'll definitely need them as your library grows).
  • @rollup/plugin-commonjs - This plugin enables the conversion to CJS so that they can be included in the final bundle
  • rollup-plugin-typescript2 - This plugin compiles the TypeScript code to JavaScript for our final bundle and generates the type declarations for the types key in package.json. The useTsconfigDeclarationDir option outputs the types to the directory specified in the tsconfig.json file.
  • rollup-plugin-postcss - This plugin helps include the CSS that we created as separate files in our final bundle. It does this by generating minified CSS from the *.css files and includes them via the <head> tag wherever used in our components.

Now as one last step let's add the script to build our component library, add the following script to your package.json file -

{
...
"scripts": {
...
"build": "rollup -c"
},
...
}

Go ahead and run yarn build from your terminal and you should be able to see the lib folder created. I would recommend exploring this folder further to understand how Rollup and its plugins generate the appropriate bundles for the CommonJS and ESM modules with the type definitions.

Don't forget to add the lib folder to .gitignore.

GitHub: Code till this step

Publishing and consuming the library

Publishing the library to NPM couldn't be any easier. Since we have already defined all the required fields in package.json, you just need to run npm publish.

Once published, you should be able to import your component from your library in the consumer application just like this -

import { Button } from "my-awesome-component-library";

You can also refer to my another article for the detailed steps and best practices for publishing a library to NPM.

You might also want to keep your library private. If you have multiple projects in a monorepo and are using something like yarn workspaces, then you don't actually need to publish the package anywhere.

Place the library folder in your monorepo and add it to your workspaces array to the package.json in the root folder -

// package.json
{
...
"workspaces": [
...
"my-awesome-component-library"
],
...
}

Then you can directly access it from any other package in your workspace by just adding it as an dependency:

// my-awesome-frontend/package.json
{
...
"dependencies": {
...
"my-awesome-component-library": 1.0.0,
...
},
...
}

Next Steps

  • Integrate Netlify or some other service to automatically deploy the Storybook whenever a PR is merged into master and to generate pull previews whenever a new PR is opened.
  • Setup test cases using React Testing library and Jest.
  • Add code-splitting to let the consumer application import only the required components instead of the whole library.

Want to get better at React, JavaScript, and TypeScript?

I regularly publish posts like this one, containing best practices, tips, and tutorials on React, JavaScript, and TypeScript. Subscribe to my newsletter to get them straight to your inbox. No spam ever. Unsubscribe at any time. You can also subscribe via RSS.

Prateek Surana

About Prateek Surana

Prateek is a Frontend Engineer and an iOS developer, currently building Devfolio. Apart from his unconditional love for JavaScript and React, he also enjoys watching Marvel movies and playing quirky games on his phone.