Build, test and release a React component library with Storybook
Tags: webdev, react, javascript, typescript
Published: 2021-03-06 22:53:17

---
canonical_url: https://www.denniskortsch.com/blog/build-test-and-release-a-react-component-library-with-storybook/
---
Whether you need reusable components internally at your job or you want to build the next Material UI, at some point you will have the need to build a component library. Fortunately tools like Storybook make it pretty easy to get setup, develop and review your React components in isolation. There is still quite some overhead in terms of configuration though which will add a lot of manual work to your todo list.

Having done this setup recently I wanted to spare you the hassle and show you a possible setup. Warning: this will be quite opinionated and I will not explain every decision or line of code. Take it more as a template that you can take and refine.

If you want to skip the step-by-step setup you can directly head to https://github.com/DennisKo/component-library-template and grab the finished code.

Main tools and libraries we will use:

- [React](https://reactjs.org/)
- [Storybook](https://storybook.js.org/)
- [Typescript](https://www.typescriptlang.org/)
- [Jest](https://jestjs.io/en/)
- [Testing-library/react](https://testing-library.com/docs/react-testing-library/intro/)
- [Rollup](https://rollupjs.org/guide/en/)
- [Changesets](https://github.com/atlassian/changesets)

## From scratch

Init a git repository and a new NPM package. We will use Yarn throughout the setup, everything is also possible with npm of course.

```bash
mkdir my-component-library  
dev cd my-component-library
git init
yarn init -y
```

Open `package.json` and change the "name" field to something you like. I chose `@dennisko/my-component-library`.

Create a `.gitignore`:
```
node_modules
lib
.eslintcache
storybook-static
```

Add `react` and `react-dom`:
```bash
yarn add -D react react-dom
```

The -D is intended as we dont want to bundle React with our library we just need it in development and as a peer dependency. Add it to your `package.json` accordingly:

```json
"peerDependencies": {
    "react": ">=17.0.1",
    "react-dom": ">=17.0.1"
 }
```

We will also install Typescript and add a `tsconfig.json`:

`yarn add -D typescript`

tsconfig.json
```json
{
  "compilerOptions": {
    "baseUrl": "./",
    "target": "es5",
    "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,
    "jsx": "react-jsx",
    "declaration": true,
    "outDir": "./lib"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "lib"]
}
```

Now we can run `npx sb init` which will install and add some default Storybook settings. It also creates some demo stories which we will not need and I suggest to delete the `./stories` folder. We will use a different structure:

```
.
└── src/
    └── components/
        └── Button/
            ├── Button.tsx
            ├── Button.stories.tsx
            └── Button.test.tsx
```

I prefer to have everything related to a component in one place - the tests, stories etc.

To tell Storybook about our new structure we have to make a small change in `.storybook/main.js`:

```javascript
"stories": [
    "../src/**/*.stories.mdx",
    "../src/**/*.stories.@(js|jsx|ts|tsx)"
  ]
```

While we are there we also edit `./storybook/preview.js` to show the Storybook [DocsPage](https://storybook.js.org/docs/react/writing-docs/docs-page) page by default.

.storybook/preview.js
```js
export const parameters = {
  actions: { argTypesRegex: '^on[A-Z].*' },
  viewMode: 'docs',
};
```

## Our first component

Now we can actually start coding and add our first component.

src/components/Button.tsx
```react
import * as React from 'react';

export interface ButtonProps {
  children: React.ReactNode;
  primary?: boolean;
  onClick?: () => void;
  backgroundColor?: string;
  color?: string;
}

export const Button = ({
  children,
  primary = false,
  onClick,
  backgroundColor = '#D1D5DB',
  color = '#1F2937',
}: ButtonProps): JSX.Element => {
  const buttonStyles = {
    fontWeight: 700,
    padding: '10px 20px',
    border: 0,
    cursor: 'pointer',
    display: 'inline-block',
    lineHeight: 1,
    backgroundColor: primary ? '#2563EB' : backgroundColor,
    color: primary ? '#F3F4F6' : color,
  };
  return (
    <button type="button" onClick={onClick} style={buttonStyles}>
      {children}
    </button>
  );
};

```

It is not a beauty, it is using hard coded colors and it is probably buggy already but it will suffice for our demo purposes.

Add two `index.ts` files to import/export our Button component.

src/components/Button/index.ts
```react
export { Button } from './Button';
```

src/index.ts
```react
export { Button } from './components/Button';
```

Your project should look like that now:

![Screenshot 2021-03-06 at 18.19.59](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ocbw4r1q6pkq7pidmkxx.png)

## Our first story

When we run `yarn storybook` now it actually builds but shows a boring screen once we open `http://localhost:6006/`.

![Screenshot 2021-03-06 at 18.24.22](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/op3pq0n0iq89cfb753a5.png)

That is because we have not added any stories for our Button component yet. A story lets us describe a state for a component and then interact with it in isolation.

Lets add some stories!

src/component/Button/Button.stories.tsx
```react
import * as React from 'react';
import { Story, Meta } from '@storybook/react/types-6-0';
import { Button, ButtonProps } from './Button';

export default {
  title: 'Button',
  component: Button,
  description: `A button.`,
  argTypes: {
    backgroundColor: { control: 'color' },
    color: { control: 'color' },
    primary: { control: 'boolean' },
  },
} as Meta;

//👇 We create a “template” of how args map to rendering
const Template: Story<ButtonProps> = (args) => <Button {...args}>Click me</Button>;

//👇 Each story then reuses that template
export const Default = Template.bind({});
Default.args = {};

export const Primary = Template.bind({});
Primary.args = {
  primary: true,
};

export const CustomBackground = Template.bind({});
CustomBackground.args = {
  backgroundColor: '#A78BFA',
};

export const CustomFontColor = Template.bind({});
CustomFontColor.args = {
  color: '#1E40AF',
};

export const OnClick = Template.bind({});
OnClick.args = {
  // eslint-disable-next-line no-alert
  onClick: () => alert('Clicked the button!'),
};

```
 
The structure and syntax here takes a bit to get used to but in general the default export in a *.stories file is used to add meta information like parameters (props in React land) and descriptions to our stories. Every named export like `export const Primary` will create a story.

Run `yarn storybook` again and we should see our Button with its stories in all its glory! 

![Screenshot 2021-03-06 at 18.37.22](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3dnivdouku2vg8jfqnev.png)
 
Play around with the UI and try to edit the Button stories, change some args (props!) and see what happens.

## Tests

Although Storybook is great to manually test and review your components we still want to have automatic testing in place. Enter Jest and React Testing Library.

Install the dependencies we need for testing:

`yarn add -D jest ts-jest @types/jest identity-obj-proxy @testing-library/react @testing-library/jest-dom`

Create a `jest.config.js` and `jest-setup.ts`.

jest.config.js
```js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  moduleNameMapper: {
    '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
      '<rootDir>/__mocks__/fileMock.js',
    '\\.(css|less|scss)$': 'identity-obj-proxy',
  },
  setupFilesAfterEnv: ['<rootDir>/jest-setup.ts'],
};
```

JSdom is the environment `react-testing` needs and although not needed in this setup the moduleNameMapper makes Jest work with images and styles. `identity-obj-proxy` is especially useful when you plan to use css modules. 

jest-setup.ts
```js
import '@testing-library/jest-dom';
```

\_\_mocks\_\_/fileMocks.js
```js
module.exports = 'test-file-stub';
```

To run the tests we add two scripts to `package.json`:

```json
    "test": "jest",
    "test:watch": "jest --watch"
```

Now we are ready to write tests for our Button.

src/components/Button/Button.test.tsx
```js
import * as React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import { Button } from './Button';

describe('Button', () => {
  test('renders a default button with text', async () => {
    render(<Button>Click me</Button>);

    expect(screen.getByText('Click me')).toBeInTheDocument();
    expect(screen.getByText('Click me')).toHaveStyle({
      backgroundColor: '#D1D5DB',
      color: '#1F2937',
    });
  });
  test('renders a primary button', async () => {
    render(<Button primary>Click me</Button>);

    expect(screen.getByText('Click me')).toHaveStyle({
      backgroundColor: '#2563EB',
      color: '#F3F4F6',
    });
  });
  test('renders a button with custom colors', async () => {
    render(
      <Button color="#1E40AF" backgroundColor="#A78BFA">
        Click me
      </Button>
    );

    expect(screen.getByText('Click me')).toHaveStyle({
      backgroundColor: '#A78BFA',
      color: '#1E40AF',
    });
  });
  test('handles onClick', async () => {
    const mockOnClick = jest.fn();
    render(<Button onClick={mockOnClick}>Click me</Button>);
    fireEvent.click(screen.getByText('Click me'));

    expect(mockOnClick).toHaveBeenCalledTimes(1);
  });
});
```

And run the tests once with `yarn test` or in watch mode with `yarn test:watch`.

![Screenshot 2021-03-06 at 22.33.02](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/dppfai5hz6r9zelwzf44.png)
 
## Bundle it for production

Until now we have a nice development setup going. Storybook (with Webpack in the background) is doing all the bundling work.

To ship our code into the world we have to create a production ready bundle. An optimized, code-split and transpiled version of our code. We will use Rollup for that. It is also possible to do it with Webpack but I still go by the rule "Webpack for apps, Rollup for libraries". I also think that the Rollup config is quite a bit more readable than a webpack config, as you can see in a moment...

`yarn add -D rollup rollup-plugin-typescript2 rollup-plugin-peer-deps-external rollup-plugin-cleaner @rollup/plugin-commonjs @rollup/plugin-node-resolve`

rollup.config.js
```js
import typescript from 'rollup-plugin-typescript2';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
import cleaner from 'rollup-plugin-cleaner';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import packageJson from './package.json';

export default {
  input: 'src/index.ts',
  output: [
    {
      file: packageJson.main,
      format: 'cjs',
      sourcemap: true,
    },
    {
      file: packageJson.module,
      format: 'esm',
      sourcemap: true,
    },
  ],
  plugins: [
    cleaner({
      targets: ['./lib'],
    }),
    peerDepsExternal(),
    resolve(),
    commonjs(),
    typescript({
      exclude: ['**/*.stories.tsx', '**/*.test.tsx'],
    }),
  ],
};
```

We take the output paths from our `package.json`, so we have to fill in the fields there and also add a "build" script:

```json
  "main": "lib/index.js",
  "module": "lib/index.esm.js",
  "scripts": {
     ...
     "build": "rollup -c"
   }
```

## Publish to NPM

To manage versions and publishing to NPM we will use a library called `changesets`. It will handle automatic patch/minor/major versions (SemVer) of our package and help us semi-automatically publishing to NPM.

`yarn add --dev @changesets/cli`

`yarn changeset init`

To make our library publicly available lets change the changeset config created at `.changeset/config.json` and change `access` to `public` and probably the `baseBranch` to `main`. Keep `access` at `restricted` if you want to keep your library private.

Now everytime you make a change in your library, in a commit or PR, you type `yarn changeset` and go through the cli and select what kind of change it was (patch/minor/major?) and add a description of your change. Based on that `changesets` will decide how to bump the version in `package.json`. So lets add a `release` script and point the `files` option `package.json` to our `lib` output directory.
 
package.json
```json
"files": [
    "lib"
  ],
 "scripts": {
    ...
    "release": "yarn build && changeset publish"
  }
```

You would think we now run `yarn release` to manually publish but `changesets` takes it even one step further and provides a Github action to automate all of it.

Create `.github/workflows/release.yml`:
```
name: Release

on:
  push:
    branches:
      - main

jobs:
  release:
    name: Release
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repo
        uses: actions/checkout@master
        with:
          # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
          fetch-depth: 0

      - name: Setup Node.js 12.x
        uses: actions/setup-node@master
        with:
          node-version: 12.x

      - name: Install Dependencies
        run: yarn

      - name: Create Release Pull Request or Publish to npm
        id: changesets
        uses: changesets/action@master
        with:
          # This expects you to have a script called release which does a build for your packages and calls changeset publish
          publish: yarn release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
```

For this to work you will need to create NPM access_token at https://www.npmjs.com/settings/NPM_USER_NAME/tokens. Choose the "Automation" option, copy the generated token and add it your github repository (under Settings -> Secrets) as `NPM_TOKEN`.

When you commit and push these changes to Github the action workflow will run and release the initial version to NPM. It will also create a release & tag in github.

Now, lets assume we make a small change in our library, like changing the description of our button. We make our code changes and run `yarn changeset`.

![Screenshot 2021-03-06 at 23.20.40](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/919qnuk06bwozpb3bg6v.png)
 
Pushing the changes to the main branch will trigger the release workflow again but this time it will not automatically publish to NPM, instead it will create a PR for us with the correctly adjusted library version. This PR will even get updated while more changes to main branch are pushed. 

![Screenshot 2021-03-06 at 23.27.00](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bqi82psxw0n2sw16uv5u.png)
 
Once we are ready and satisfied with our changes we can merge that PR, which will trigger a publish to NPM again with the appropriate version. 

---

Thats it. We build, tested and released a React component library!

Thanks for reading! I happily answer questions, and chat about possible bugs and improvements. 

Also Follow me on Twitter: https://twitter.com/DennisKortsch