Web development
How to use Storybook without endless upkeep
One story per state, not per prop combination: play tests the interaction, the Vitest addon runs it in CI and automigrate keeps majors alive.
Key points
- One story per state: prop combinations belong in the controls, not in more stories.
- `play` runs the test inside the story and reaches CI through the Vitest addon.
- Every story duplicates the component props, so upkeep grows with their number.
Storybook earns its keep with one rule: one story per state, not one per prop combination. The version measured is storybook 10.6.5.
Initialise and build
npx storybook@latest init
npm run storybook
npx storybook build --output-dir storybook-static
init detects the framework, adds the scripts and creates the .storybook folder. build produces static files: serve them with any static server, with no Node in production.
One story, one state
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { LoginForm } from "./LoginForm";
const meta = {
component: LoginForm,
args: { onSubmit: fn() },
parameters: { layout: "centered" },
tags: ["autodocs"],
} satisfies Meta;
export default meta;
type Story = StoryObj;
export const Empty: Story = {};
meta.component alone generates the controls for the props; tags: ["autodocs"] adds the documentation page. When a story introduces no new state, delete it rather than keep it.
The interaction test inside the story
import { expect } from "storybook/test";
import { Empty } from "./LoginForm.stories";
export const Filled: StoryObj = {
play: async ({ args, canvas, userEvent }) => {
await userEvent.type(canvas.getByLabelText("Email"), "[email protected]");
await userEvent.type(canvas.getByLabelText("Password"), "segreta");
await userEvent.click(canvas.getByRole("button", { name: "Accedi" }));
await expect(args.onSubmit).toHaveBeenCalledWith({
email: "[email protected]",
password: "segreta",
});
},
};
play runs in the Storybook browser and in CI through the Vitest addon. expect and userEvent come from storybook/test, with the same queries Testing Library uses.
Run the stories as tests
npm i -D @storybook/addon-vitest
npx vitest run
The addon registers a Vitest project that runs every story in a browser. The report names the story, not the file, so a red points straight at the state that broke.
Upgrade without breaking
npx storybook@latest upgrade
npx storybook@latest automigrate
upgrade aligns every @storybook/* dependency to the same major; automigrate applies the automatic migrations and prints the manual ones, with the file to edit.
A named limitation
Every story duplicates the component props: when the component signature changes, the stories fall behind and show a state that no longer exists. The static build grows with the story count, and Autodocs doubles the pages to maintain.
Where to continue
Three components worth documenting: popover-form, accordion-05 and the icon grid; the rest sits in components/lib and the 1,556 previews. Interaction tests connect to Vitest in a React project, the queries to Testing Library: five tests. The block catalogue comparison is in 21st.dev vs shadcnblocks.
Frequently asked questions
How many stories does a component need?
One per visible state: empty, loading, error, filled. Prop combinations are explored with the controls in that story, not with one story per value.
Does the play function replace Vitest tests?
No. The play function checks the interaction inside the story; a Vitest test checks the component with mocks and isolated assertions. You need both, at different levels.
Does Storybook need a server in production?
No. storybook build produces static HTML, JavaScript and CSS: serve them with nginx or a CDN, and no Node process is required.
How do I upgrade a major without rewriting the stories?
With storybook upgrade followed by automigrate. The second command applies the automatic changes and prints the ones that need a manual decision.
Can the stories drift away from the component?
Yes, and that is the main risk: props are duplicated in the story file. A type built with satisfies Meta makes the error visible in TypeScript, but does not prevent it.