Setting Up Storybook for React Components

WEB
8 min read

Here at DoltHub, we're building a React library to share hooks, contexts, utilities, and components between our web applications. Storybook has been a useful tool for testing and reviewing UI changes before we publish our React components. This blog will walk you through how we set up Storybook to test the UI for our React components and create a better CI workflow.

Why Storybook?

Storybook is a frontend workshop for building UI components and pages in isolation.

We're building a React component library to consolidate components we use among our four web applications. Some of these components are identical between applications, and some have subtle differences.

We needed a way to test these components in isolation and easily recognize changes among different UI variants. Without Storybook, we would need to copy over a component to the library, update it with any necessary variants, and then spin up a local server for each of our web applications and test the component in each environment. This was prone to error, as testing certain states of some components could be difficult and time intensive.

Storybook captures UI variations as "stories", where we can supply different props and mock data to simulate all states in different environments before we test the component within our applications.

In combination with Chromatic, we can write UI tests and diff UI changes to better catch UI regressions in CI, which we have relied on error-prone unit tests for in the past.

It also helps us better collaborate with our web designer. He can view our components in one place, as well as test user interactions and change props from the Storybook UI.

Setting up Storybook

Our React library is a monorepo of general-use React components, hooks, contexts, and utilities. We want to set up Storybook specifically for our components package.

First, we install Storybook in our components package.

components % npx storybook@latest init

This command may ask a few questions about your desired bundler and framework. Then it installs the required dependencies, sets up scripts to run and build Storybook, and adds default configuration and some boilerplate stories.

We should now be able to start Storybook and go through their tutorial.

components % yarn storybook

Storybook tutorial

Depending on your setup this may be enough to get you started and you can skip to the Writing a story section. However, if you use CSS modules and TailwindCSS to style your components like we do, you may require some additional configuration.

Additional configuration for CSS modules and Tailwind

To use CSS modules and Tailwind with Storybook, we first need to install two additional packages, @storybook/addon-styling-webpack and postcss-loader.

components % yarn install @storybook/addon-styling-webpack postcss-loader

Then we add webpack configuration to .storybook/main.ts that loads the PostCSS plugins needed for Tailwind.

const config: StorybookConfig = {
  // additional config
  webpackFinal: async (config) => {
    (config.module?.rules ?? []).push({
      test: /\.css$/,
      use: [
        {
          loader: "postcss-loader",
          options: {
            postcssOptions: {
              plugins: {
                "tailwindcss/nesting": {},
                tailwindcss: {},
                "postcss-preset-env": {},
                cssnano: {},
                autoprefixer: {},
              },
            },
          },
        },
      ],
      include: resolve(__dirname, "../"),
    });
    return config;
  },
};

Now when we write our first story below, our Tailwind classes should work correctly.

We took one additional step to make our CSS module class names more readable in Storybook. Storybook CSS module class names default to a hash (i.e. kypUKhWmF7muntdUGk3g), whereas we use a more readable class name pattern when we build our components using Rollup: [folder]_[local]__[hash:base64:5]. The default Storybook class name hash makes it more difficult to debug components in Storybook by inspecting the elements using browser dev tools.

Storybook hash classnames

We can match our Rollup class name pattern by installing the storybook-css-modules addon and adding the following configuration to our .storybook/main.ts file.

const config: StorybookConfig = {
  // other config,
  addons: [
    // other addons
    {
      name: getAbsolutePath("storybook-css-modules"),
      options: {
        cssModulesLoaderOptions: {
          importLoaders: 1,
          modules: {
            localIdentName: "[folder]_[local]__[hash:base64:5]",
          },
        },
      },
    },
  ],
};

Now if we inspect a component we see our readable class names.

Storybook readable classnames

You can see our full Storybook configuration on GitHub.

Writing a story

Now that Storybook is configured and ready to go, we can add our first story, which captures the rendered state of a UI component. We'll start with a simplified version of our Button component, which looks something like this.

// components/src/Button/index.tsx
import cx from "classnames";
import React, { ButtonHTMLAttributes } from "react";
import css from "./index.module.css";

type VariantProps = {
  red?: boolean;
  green?: boolean;
  small?: boolean;
};

type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
type Props = ButtonProps & VariantProps;

function Button({
  children,
  className,
  red = false,
  green = false,
  small = false,
  ...props
}: Props) {
  return (
    <button
      className={cx(
        css.button,
        {
          [css.red]: red,
          [css.green]: green,
          [css.small]: small,
        },
        className
      )}
      type="button"
      {...props}
    >
      {children}
    </button>
  );
}

export default Button;

This is a pretty standard looking blue button, with variants to make the background color red or green and make the button smaller. We want to test our different variant props using Storybook.

We create a new story file in components/src/stories called Button.stories.tsx and add our default Button.

// components/src/stories/Button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import Button from "../Button";

const meta: Meta<typeof Button> = {
  title: "Button",
  component: Button,
  tags: ["autodocs"],
  parameters: {
    layout: "centered",
  },
};

export default meta;

type Story = StoryObj<typeof Button>;

export const Default: Story = {
  args: {
    children: "Button name",
  },
};

When we run Storybook, we see our default Button and the auto-generated documentation.

Default button

Looks good so far! Now we add additional stories for the different button variants.

export const Green: Story = {
  args: {
    ...Default.args,
    green: true,
  },
};

export const Red: Story = {
  args: {
    ...Default.args,
    red: true,
  },
};

export const Small: Story = {
  args: {
    ...Default.args,
    small: true,
  },
};

And we will see Storybook update with the button variants.

Button variants

Now if we update any code related to our Button component, we will see the updated UI automatically in Storybook.

Interactions

We can add interactions to our stories to test scenarios that otherwise require user interaction. These are helpful for catching changes to user interactions in components.

First we install the necessary addons.

yarn add -D @storybook/test @storybook/addon-interactions

We can use the play function in our Button story to a build a component interaction that tests if the onClick prop is called when a user clicks the button.

import { expect, fn, userEvent, waitFor, within } from "@storybook/test";

// ...

export const Default: Story = {
  args: {
    children: "Button name",
    onClick: fn(),
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole("button");

    await userEvent.click(button);
    await waitFor(async () => expect(args.onClick).toHaveBeenCalled());
  },
};

When we start Storybook and navigate to our default Button story, we can see this interaction run in the "Interactions" tab.

Interactions

If we make a change to Button that prevents the user from successfully clicking, we'll see that failure.

Failed interaction

Toggling themes

The components in our React library are shared among a few different web applications, each with a different color theme. We utilize Tailwind themes to configure these different color themes.

Essentially the custom Tailwind color named button-1, used for the background color of our Button component, has a different rgba values for each application.

We can create a theme toggle in our Storybook toolbar to view our components with different color themes applied.

FormSelect Storybook with toggle

If you want to know more about creating dynamic Tailwind themes or adding a theme toggle to your Storybook, check out this previous article

Using Chromatic in CI

What we have so far is a great improvement to the developer experience for viewing UI additions or changes to React components. However, we also want to utilize Storybook for catching UI bugs in CI (continuous integration) and for UI reviews from other team members.

This is where Chromatic comes in. Chromatic is a visual testing and review tool that uses Storybook to scan every possible UI state across browsers to catch visual and functional bugs.

Our workflow

Every component in our React component library must come with a story. If a developer makes a change to a component, like changing the red variant color for a Button for example, they can use Storybook to see how the change affects the component UI.

When the developer is satisfied with their changes, they'll open a pull request. CI runs on every pull request, and includes standard checks like unit tests, lint, and compiling and building successfully.

{
  "scripts": {
    "ci": "yarn prettier && yarn lint && yarn compile && yarn build && yarn test"
  }
}

If these tests pass, the changes are published to Chromatic. This adds two additional checks to our pull request: UI Testing and UI Review.

UI Tests pinpoint visual changes and verify user interactions. Whenever a developer pushes new code, Chromatic generates a new set of snapshots and compares them against the baseline. If there are test errors or unintended changes, they can verify the changes are intentional.

If any of our Storybook interactions fail, we'll get a Chromatic build error. This can happen if our play tests that we wrote above fail.

Failing PR checks

We can see what components were affected by the error and how by clicking on the UI Tests link in our PR.

Failing UI Test

The developer fixes the failing test and pushes again to the PR, triggering another Chromatic build.

PR checks with Chromatic

This build passes and they can review the UI build for any unintended changes.

UI build

Once the developer has verified the changes, the UI Tests check on GitHub will pass and we can move to UI Review.

UI Review looks similar to a pull request on GitHub. We can tag teammates as reviewers and give direct feedback on components within this page.

UI Review pending

However, instead of viewing diffs of code changes, you view diffs of UI changes. Changing the hex of our red Tailwind color class results in a diff of every component affected by the change.

UI Review diff

Our designer and other decision makers can review and discuss the change until they are ready to give their stamp of approval.

Once all tagged reviewers approve, our GitHub pull request will pass all checks and we can merge to main. Whenever we merge to main we make sure to build Chromatic on this branch. This gives us a permalink that will always have the most up-to-date version of our components.

You can see our Storybook permalink here.

Setting up Chromatic for CI using GitHub Actions

We use GitHub Actions to run CI on all of our pull requests. Our React library has two workflows: one for CI on pull requests and one for publishing Chromatic on merges to main.

Since we're using a monorepo, we only want to build Chromatic for CI on changes to our components package. We use paths-filter to achieve this. This is what our CI GitHub Actions workflow looks like:

# .github/workflows/ci.yaml
name: Run CI
on:
  pull_request:
    paths:
      - "**"
  workflow_dispatch:

jobs:
  ci:
    runs-on: ubuntu-22.04
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: dorny/paths-filter@v2
        id: filter
        with:
          filters: |
            components:
              - 'packages/components/**'

      - name: Enable Corepack before setting up Node
        run: corepack enable

      - uses: actions/setup-node@v4
        with:
          node-version: "18"

      - name: Install and run CI
        working-directory: ./
        run: |
          yarn install --immutable
          yarn ci

      - name: Publish to Chromatic
        if: steps.filter.outputs.components == 'true' && github.event.pull_request.draft == false
        uses: chromaui/action@latest
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
          workingDir: packages/components
          onlyChanged: true
          skip: "dependabot/**"

Our workflow for publishing Chromatic on merges to main is very similar, but skips the CI step. You can see that workflow here.

Conclusion

Storybook is a great tool that can improve your developer experience and review process when working with UI changes. As we've been moving some of our common web components over to a separate React library, it has helped us catching existing and new UI bugs that would have been more difficult to catch otherwise.

Have questions or feedback? Find me (@taylorb) in our Discord.

SHARE

JOIN THE DATA EVOLUTION

Get started with Dolt

Or join our mailing list to get product updates.