Build, test and release a React component library with Storybook

denniskortsch

Dennis Kortsch

Posted on March 6, 2021

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:

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.

mkdir my-component-library  
dev cd my-component-library
git init
yarn init -y
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Add react and react-dom:

yarn add -D react react-dom
Enter fullscreen mode Exit fullscreen mode

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:

"peerDependencies": {
    "react": ">=17.0.1",
    "react-dom": ">=17.0.1"
 }
Enter fullscreen mode Exit fullscreen mode

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

yarn add -D typescript

tsconfig.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"]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

"stories": [
    "../src/**/*.stories.mdx",
    "../src/**/*.stories.@(js|jsx|ts|tsx)"
  ]
Enter fullscreen mode Exit fullscreen mode

While we are there we also edit ./storybook/preview.js to show the Storybook DocsPage page by default.

.storybook/preview.js

export const parameters = {
  actions: { argTypesRegex: '^on[A-Z].*' },
  viewMode: 'docs',
};
Enter fullscreen mode Exit fullscreen mode

Our first component

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

src/components/Button.tsx

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>
  );
};

Enter fullscreen mode Exit fullscreen mode

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

export { Button } from './Button';
Enter fullscreen mode Exit fullscreen mode

src/index.ts

export { Button } from './components/Button';
Enter fullscreen mode Exit fullscreen mode

Your project should look like that now:

Screenshot 2021-03-06 at 18.19.59

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

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

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!'),
};

Enter fullscreen mode Exit fullscreen mode

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

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

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'],
};
Enter fullscreen mode Exit fullscreen mode

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

import '@testing-library/jest-dom';
Enter fullscreen mode Exit fullscreen mode

__mocks__/fileMocks.js

module.exports = 'test-file-stub';
Enter fullscreen mode Exit fullscreen mode

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

    "test": "jest",
    "test:watch": "jest --watch"
Enter fullscreen mode Exit fullscreen mode

Now we are ready to write tests for our Button.

src/components/Button/Button.test.tsx

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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

Screenshot 2021-03-06 at 22.33.02

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

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'],
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

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

  "main": "lib/index.js",
  "module": "lib/index.esm.js",
  "scripts": {
     ...
     "build": "rollup -c"
   }
Enter fullscreen mode Exit fullscreen mode

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

"files": [
    "lib"
  ],
 "scripts": {
    ...
    "release": "yarn build && changeset publish"
  }
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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

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

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

💖 💪 🙅 🚩
denniskortsch
Dennis Kortsch

Posted on March 6, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related