Skip to content
FLAVIO COPES
flaviocopes.com

Storybook tutorial: build and test UI components in isolation

By

Use Storybook with React to develop components in isolation, model loading and error states, test interactions and accessibility, and run CI.

~~~

An application rarely shows a component in only one state.

A user card might be:

Those states can be hard to reach by clicking through the whole application.

Storybook renders components in isolation. A story describes one useful state, using normal component props.

In this tutorial we’ll add Storybook to a React project and build a small UserCard through its stories.

Then we’ll add interaction and accessibility tests and run them in CI.

Install Storybook

Inside an existing React project that uses Vite, run:

npm create storybook@latest

The initializer detects the framework, installs packages, creates .storybook, and adds example stories.

Start it:

npm run storybook

Storybook opens separately from the application. The sidebar lists stories, and the canvas renders the selected component.

Build the component

Create src/components/UserCard.jsx:

export function UserCard({
  name,
  role,
  avatarUrl,
  loading = false,
  error = '',
  onMessage,
}) {
  if (loading) {
    return (
      <article aria-busy="true" className="user-card">
        <p>Loading user...</p>
      </article>
    )
  }

  if (error) {
    return (
      <article className="user-card" role="alert">
        <p>{error}</p>
      </article>
    )
  }

  return (
    <article className="user-card">
      {avatarUrl ? (
        <img src={avatarUrl} alt="" width="64" height="64" />
      ) : (
        <span aria-hidden="true" className="user-card__placeholder">
          {name.slice(0, 1)}
        </span>
      )}

      <div>
        <h2>{name}</h2>
        <p>{role}</p>
        <button type="button" onClick={() => onMessage(name)}>
          Message {name}
        </button>
      </div>
    </article>
  )
}

A story should use the same component the application imports. Do not build a special Storybook-only copy.

Write the first story

Create src/components/UserCard.stories.jsx:

import { fn } from 'storybook/test'
import { UserCard } from './UserCard'

const meta = {
  title: 'People/UserCard',
  component: UserCard,
  tags: ['autodocs'],
  args: {
    onMessage: fn(),
  },
}

export default meta

export const Default = {
  args: {
    name: 'Ada Lovelace',
    role: 'Mathematician',
    avatarUrl: 'https://i.pravatar.cc/128?img=47',
  },
}

The default export contains component metadata.

The named export is a story. args are the props Storybook passes to the component.

The fn() spy lets Storybook record button calls and lets our tests assert on them.

Model every important state

Add more stories:

export const WithoutAvatar = {
  args: {
    name: 'Grace Hopper',
    role: 'Computer scientist',
    avatarUrl: '',
  },
}

export const Loading = {
  args: {
    loading: true,
  },
}

export const Error = {
  args: {
    error: 'Could not load this user',
  },
}

export const LongContent = {
  args: {
    name: 'A very long name that must not break the card layout',
    role: 'Principal engineer working across several product teams',
    avatarUrl: '',
  },
}

This is where Storybook changes how we build UI.

Instead of waiting for the API to fail, we select the Error story. Instead of changing database data to produce a long name, we edit one story.

A story is useful when it represents a state we want to see again.

Use controls

Storybook generates controls from args. Open the Controls panel and edit name, role, or loading.

Add explicit control information when inference is not enough:

const meta = {
  title: 'People/UserCard',
  component: UserCard,
  tags: ['autodocs'],
  argTypes: {
    role: {
      control: 'text',
      description: 'The role shown below the name',
    },
    avatarUrl: {
      control: 'text',
    },
  },
  args: {
    onMessage: fn(),
  },
}

Controls are excellent for exploration. Named stories are still important because they preserve reviewed states and can run as tests.

Add a decorator

Components often depend on layout or context.

A decorator wraps a story:

const meta = {
  // ...
  decorators: [
    (Story) => (
      <div style={{ maxWidth: 420, padding: 24 }}>
        <Story />
      </div>
    ),
  ],
}

Global decorators belong in .storybook/preview.js:

import '../src/index.css'

export default {
  decorators: [
    (Story) => (
      <div className="storybook-page">
        <Story />
      </div>
    ),
  ],
}

Use the same providers and global CSS the app needs. Avoid recreating the whole application shell around every small component.

Write an interaction test

A renderable story is already a basic smoke test: the component must render without throwing.

For behavior, add a play function:

import { expect, fn } from 'storybook/test'

export const SendsMessage = {
  args: {
    name: 'Ada Lovelace',
    role: 'Mathematician',
    avatarUrl: '',
  },

  play: async ({ args, canvas, userEvent }) => {
    const button = canvas.getByRole('button', {
      name: 'Message Ada Lovelace',
    })

    await userEvent.click(button)

    await expect(args.onMessage).toHaveBeenCalledWith(
      'Ada Lovelace'
    )
  },
}

The play function runs after the story renders.

canvas provides Testing Library queries scoped to this story. Query by role and accessible name, as a person using assistive technology would find the control.

The Interactions panel shows each step and makes failures easier to debug.

Test keyboard behavior

A button already supports keyboard activation because we used a real <button>.

We can preserve that behavior with a story:

export const KeyboardMessage = {
  args: {
    name: 'Grace Hopper',
    role: 'Computer scientist',
    avatarUrl: '',
  },

  play: async ({ args, canvas, userEvent }) => {
    await userEvent.tab()

    const button = canvas.getByRole('button', {
      name: 'Message Grace Hopper',
    })

    await expect(button).toHaveFocus()
    await userEvent.keyboard('{Enter}')
    await expect(args.onMessage).toHaveBeenCalled()
  },
}

If the story’s wrapper contains other focusable elements, tab the number of times the real order requires. Do not force focus in the test and hide a broken tab order.

Add accessibility checks

Install the accessibility addon if the initializer did not include it:

npx storybook add @storybook/addon-a11y

The accessibility panel checks the rendered story with automated rules.

It can catch:

Automated checks cannot prove that a component is accessible. Test keyboard use and screen-reader behavior manually too.

Our decorative avatar uses alt="", while the person’s name appears as real text. Repeating the same name in the image alternative would add noise.

View mobile and dark states

Storybook’s viewport tools let us render a story at phone-sized widths.

Preserve a narrow state as a story parameter:

export const Mobile = {
  args: {
    name: 'Ada Lovelace',
    role: 'Mathematician',
    avatarUrl: '',
  },
  parameters: {
    viewport: {
      defaultViewport: 'mobile1',
    },
  },
}

Themes can be handled with a decorator that adds the same class or data attribute the application uses.

Do not make a separate dark component. Theme the same component.

Mock data at the network boundary

For a component that fetches data, stories should not depend on a live development API.

Use Storybook’s supported network mocking setup, commonly Mock Service Worker, to describe:

The component still calls fetch(). The story controls the response at the network boundary.

This is more realistic than adding a fakeError prop that does not exist in production.

Run stories as tests

For a Vite-based Storybook, add the Vitest addon:

npx storybook add @storybook/addon-vitest

Storybook Test turns stories into browser tests. Render tests, play functions, and configured accessibility checks can run from the Storybook UI and from the command line.

Use the scripts generated by the addon. A typical CI step is:

- name: Run Storybook tests
  run: npm run test-storybook

Storybook’s current test tooling uses Vitest browser mode and Playwright to render stories. Use a CI image with the required browser installed, or install it in the workflow.

Also build the static Storybook:

npm run build-storybook

This catches missing assets, invalid imports, and configuration that worked only in the development server.

What deserves a story?

Do not create stories for random prop combinations.

Create them for:

When a UI bug is reported, first reproduce it as a story. Then fix the component and keep the story as a regression test.

Keep stories close to the component

A practical structure is:

src/components/UserCard/
  UserCard.jsx
  UserCard.css
  UserCard.stories.jsx
  UserCard.test.jsx

Stories are part of the component’s documentation and test surface, not marketing screenshots stored elsewhere.

Review them when the component API changes. A story that no longer represents a real application state is misleading even if it still renders.

We now have a small component workshop containing normal, loading, error, long-content, keyboard, and mobile states. The stories also test rendering and interaction behavior.

The main benefit of Storybook is not the sidebar. It is making hidden UI states cheap to create, inspect, discuss, and test before they surprise us inside the full application.

Tagged: React · All topics
~~~

Related posts about react: