Storybook Mock Data: Fixtures for Every Component State
Storybook mock data decides how much of a component library is actually tested. A story renders exactly the data you hand it, and hand-written args usually settle on one tidy example: five users, short names, every avatar present. Real UIs break on other data: the empty list, the 200-item list, the 60-character name, the missing avatar, the null optional field, the email address with no place to wrap. This post generates one fixture set per component state (empty, typical, many, extreme) from a single template. A fixed seed pins the output, so your visual-regression snapshots change only when the UI changes, not because the data was reshuffled.
JsonFabrica's part is small and specific. It returns generated JSON over HTTP. A short script you own calls it, writes the results to JSON files in your repo, and your stories import those files. There's no Storybook addon and no Chromatic or Percy integration involved.
This isn't about mocking network responses so a frontend can run without a backend. Mock API response data covers that. Here the data goes straight into a component's props.
Why a single happy-path story isn't enough
Most story files start with one story per component, and its args are whatever the author typed while building it:
export const Default: Story = {
args: {
users: [
{ id: 'u1', name: 'Ada Lovelace', email: '[email protected]' },
{ id: 'u2', name: 'Alan Turing', email: '[email protected]' },
],
},
};
That story passes review, and it will keep passing, because nothing in it can stress the layout. The bugs that reach production live in data the author didn't think to type:
- No items at all. The list renders an empty
<ul>with a border and no message, or it shows a "no results" message meant for search. - Hundreds of items. The container grows past the page because it has no max height, the scroll area clips the last row, or the pagination control never appeared because nobody had more than a page.
- A 60-character name. It pushes the role badge off the card or wraps onto three lines and breaks the row height.
- A long unbroken string. An email address has no spaces to wrap at,
so it overflows its cell unless the CSS says
overflow-wrap: anywhereor truncates it. - Missing optional fields.
avatarUrl: nullrenders a broken image icon, andteam: nullrenders the literal text "null". - A large number. An unread count of 1,000,000 doesn't fit in a badge designed for "3".
These are the same kinds of edge values that boundary value test data covers for application logic. In a component library they show up as layout bugs, and a story is the cheapest place to catch them.
Mock data for component states: empty, typical, many, extreme
Give every data-driven component four fixture states:
| State | What it contains | What it catches |
|---|---|---|
empty |
Zero items | Missing empty states, collapsed containers |
typical |
A handful of realistic items | The baseline everyone designs for |
many |
200 items | Scrolling, virtualization, pagination, performance |
extreme |
One item per edge case | Overflow, wrapping, nulls, oversized numbers |
Loading and error states are different. They usually come from props
such as isLoading or error, not from the data, so they get their own
stories with ordinary args. The four states above are about the data the
component renders once it has some.
The extreme state puts each edge case in its own row instead of piling every edge case onto one row. When the snapshot diff shows a broken row, you know which value broke it.
One template that generates every state
Write the four states as one template with a state switch. The caller
passes state as a generation parameter, and
getParam reads it, falling back to
typical if it's missing. An if/elseIf chain (see
control flow) sets the row count, and a for loop
writes the rows:
<setVar('state', getParam('state', 'typical'))>
<setVar('n', 6)>
<if(getVar('state') == 'empty')><setVar('n', 0)>
<elseIf(getVar('state') == 'many')><setVar('n', 200)>
<elseIf(getVar('state') == 'extreme')><setVar('n', 4)>
<endIf>
{"state": "<getVar('state')>", "users": [
<for(i, 1, getVar('n'))><if(getVar('i') > 1)>,<endIf>
<setVar('edge', 'none')>
<if(getVar('state') == 'extreme')>
<setVar('edge', getVar('i'))>
<endIf>
<setVar('name', getRandomFullName())>
<setVar('email', getRandomEmail('example.com'))>
<setVar('unread', getRandomNumber(0, 9))>
<if(getVar('edge') == 1)>
<setVar('name', getRandomTextWithSpaces(60, 60))>
<elseIf(getVar('edge') == 2)>
<setVar('email', appendBefore('x', '@example.com', 76))>
<elseIf(getVar('edge') == 4)>
<setVar('unread', 1000000)>
<endIf>
{
"id": "u<appendBefore('0', getVar('i'), 3)>",
"name": "<getVar('name')>",
"email": "<getVar('email')>",
"role": "<getRandomElement('member', 'member', 'member', 'admin')>",
"avatarUrl": <if(getVar('edge') == 3)>null
<else>"/avatars/<getRandomNumber(1, 12)>.png"<endIf>,
"team": <if(getVar('edge') == 3)>null
<else>"<getRandomElement('Platform', 'Growth', 'Billing')>"<endIf>,
"unreadCount": <getVar('unread')>,
"lastActiveAt": "<getRandomDate('2026-06-01', '2026-09-30')>"
}<end_for>
]}
How each state comes out:
- Empty sets the count to 0. A
forloop whose end is lower than its start doesn't run, sousersis[]. - Typical writes 6 ordinary rows. The role comes from
getRandomElement, which picks one value uniformly. Listing'member'three times makes members three times as common as admins. - Many writes 200 rows of the same shape.
- Extreme writes 4 rows and uses the row index as the edge case.
Row 1's name is exactly 60 characters from
getRandomTextWithSpaces. The words are lorem-style placeholders, which is fine for layout testing. Row 2's email is 64xcharacters followed by@example.com, built withappendBefore. Sixty-four characters is the longest local part an email address may have. Row 3 has a literalnullavatar and team. Row 4 has an unread count of 1,000,000.
Two details make this work. Loop variables are read with getVar('i'),
not a bare i. And template expressions have comparison and boolean
operators but no arithmetic, so the edge cases are chosen by comparing
the index to fixed values, not computed from it. The comma guard
<if(getVar('i') > 1)>,<endIf> puts a comma before every row except
the first.
Avatar URLs point at local paths like /avatars/7.png, not a remote
image service. Put a dozen small images in a folder that Storybook
serves through staticDirs in .storybook/main. A remote avatar can
load slowly or not at all during a snapshot run, and then the snapshot
captures a half-loaded image.
The 200-row state stays well within the engine's per-generation limits
(2 seconds, 100,000 node evaluations, 10,000 loop iterations, and 8 MiB
of output). It uses about a fifth of the node budget. Save the
template with POST /v1/templates and
note the templateId it returns.
Generate Storybook fixtures into committed JSON files
Call the API from a script you run on demand, not from Storybook. The script asks for each state with the same fixed seed and writes one JSON file per state next to the component:
// scripts/generate-story-fixtures.mjs
// Run on demand: node scripts/generate-story-fixtures.mjs
import { mkdir, writeFile } from 'node:fs/promises';
const API = 'https://api.jsonfabrica.com/v1';
const TEMPLATE_ID = 'tpl_user_list'; // the id POST /v1/templates returned
const SEED = 20261001; // change only when you mean to re-baseline
const STATES = ['empty', 'typical', 'many', 'extreme'];
const OUT_DIR = 'src/components/UserList/fixtures';
await mkdir(OUT_DIR, { recursive: true });
for (const state of STATES) {
const url = `${API}/templates/${TEMPLATE_ID}/generate`;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.JSONFABRICA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ seed: SEED, params: { state } }),
});
if (!res.ok) throw new Error(`${state}: HTTP ${res.status}`);
const { data, meta } = await res.json();
if (meta.seed !== SEED) throw new Error(`${state}: seed not applied`);
const file = `${OUT_DIR}/${state}.json`;
await writeFile(file, JSON.stringify(data, null, 2) + '\n');
console.log(`${file}: ${data.users.length} users`);
}
The response's data is the generated document, already parsed as
JSON, and meta.seed echoes the seed that was used. The script checks
that echo, so a typo that drops the seed fails loudly instead of quietly
writing random fixtures. Running it prints:
src/components/UserList/fixtures/empty.json: 0 users
src/components/UserList/fixtures/typical.json: 6 users
src/components/UserList/fixtures/many.json: 200 users
src/components/UserList/fixtures/extreme.json: 4 users
Commit the four files. From then on, Storybook, your test runner, and your CI snapshot job read them from disk. Nothing calls the network, nothing needs an API key, and a JsonFabrica outage can't fail a snapshot run. The API key is only needed on the machine of whoever regenerates the fixtures, which is a deliberate, reviewed step.
Four calls is the right shape here because each one sends a different
state parameter. If you'd rather keep the template body itself in the
repo next to the fixtures, POST /v1/templates/generate accepts a raw
template body with the same seed and params and saves nothing.
Pass Storybook mock data to stories as args
In Component Story Format 3, default args go on the meta object and each
story overrides what it needs. Vite and webpack builds can import JSON
files directly. In TypeScript, that needs resolveJsonModule in your
tsconfig:
// src/components/UserList/UserList.stories.tsx
// Import Meta and StoryObj from your framework package.
import type { Meta, StoryObj } from '@storybook/react-vite';
import { UserList } from './UserList';
import empty from './fixtures/empty.json';
import typical from './fixtures/typical.json';
import many from './fixtures/many.json';
import extreme from './fixtures/extreme.json';
const meta = {
component: UserList,
args: { users: typical.users },
} satisfies Meta<typeof UserList>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Typical: Story = {};
export const Empty: Story = { args: { users: empty.users } };
export const Many: Story = { args: { users: many.users } };
export const Extreme: Story = { args: { users: extreme.users } };
The package name in the type import depends on your framework and
Storybook version, so use whichever your existing stories already
import from. TypeScript infers JSON fields as plain string, so if your
User type declares role as 'member' | 'admin', cast the fixture
arrays (as User[]) or parse them with your schema library once in a
small fixtures.ts module.
Four stories now cover four states, and each one gets its own snapshot. The empty story makes you design an empty state, the many story shows whether the list scrolls inside its container or stretches the page, and the extreme story shows exactly which row overflowed.
Stable data for visual regression tests
A visual-regression tool, whether that's Chromatic, Percy, or Playwright screenshots of your built Storybook, compares each story's render to an approved baseline. Every diff costs someone a review. If the data can change between runs, you get diffs that have nothing to do with the UI, and reviewers learn to approve them without looking.
Committed fixtures from a fixed seed remove the data as a source of change. The same seed and the same template give the same output, so rerunning the script produces byte-identical files and no diff at all, as long as the template and the generator are unchanged. Reproducible test data from seeds goes deeper into how seeds work and how to capture them.
There's one caveat. A seed pins the sequence of random draws, not the meaning of each draw. If you edit the template and add or remove a random call, every value generated after that point shifts, and the next regeneration changes most of the fixture. That's fine, as long as it happens on purpose:
- Change the template, or bump
SEEDto re-baseline. - Run the script and commit the changed JSON in the same pull request as the component change.
- Review the JSON diff and the visual diffs together, then accept the new baselines.
Because the fixtures are committed, nothing outside your repo can move your baselines. Only rerunning the script can, and that shows up as a file change in review.
Data isn't the only source of flaky snapshots. Web fonts that load late, CSS animations, and remote images all cause their own diffs, and your visual testing tool's docs cover how to handle them. Fixed fixtures take care of the data.
Relative dates still need a frozen clock
The template writes lastActiveAt as an absolute ISO-8601 timestamp
inside a fixed range, via
getRandomDate. The committed value
never changes. But if the component renders it as "3 days ago", the
label is computed from the real current time on every run, and the
snapshot drifts as the calendar moves.
JsonFabrica can't fix that. Templates have no "now" function. Dates come
out as absolute timestamps in the range you specify, and formatDate
only formats a given date with YYYY, MM, DD, HH, mm, and ss
tokens. Freezing the clock is a job for your component or your
Storybook setup:
- Pass "now" in as a prop. A
nowprop defaulting tonew Date()lets every story pass a fixed date. This is the most explicit option and keeps the component easy to test elsewhere. - Freeze
Datein story setup. Storybook 8.2 and later support abeforeEachhook on stories, components, or the whole project, and the Storybook docs show it used with themockdatepackage to pin the date and reset it afterwards.
Whichever you choose, pick a frozen date just after the template's date
range, such as 2026-10-01 for a range ending 2026-09-30. Then every
generated date is in the past and the relative labels read sensibly.
Live data with a loader, for local exploration only
Sometimes you want to see a component with data you haven't looked at
yet. The simplest way is to run the script with a different SEED and
not commit the result. If you'd rather fetch data inside Storybook, a
loader can do it. Loaders run before the story renders and pass their
results to the render function as loaded:
export const LiveTypical: Story = {
// Exclude from snapshots: this data changes on every load.
parameters: { chromatic: { disableSnapshot: true } },
loaders: [
async () => {
// Your own local proxy, which adds the API key server-side.
const res = await fetch(
'http://localhost:8787/user-list?state=typical',
);
return { users: (await res.json()).users };
},
],
render: (args, { loaded }) => (
<UserList {...args} users={loaded.users} />
),
};
Two rules keep this from leaking into CI. First, the browser must never
call JsonFabrica directly. Anything in a story ends up in the static
Storybook build, including an API key, so route the call through a
local proxy that holds the key. Second, exclude the story from snapshot
and test runs: the Chromatic parameter above for Chromatic, or your
tool's equivalent. Storybook 8.1 and later can also exclude a story from
test runs with the !test tag. A story whose data changes on every
load can't have a baseline.
FAQ
How do I add mock data to Storybook stories?
Pass it in as args. Import a JSON fixture file into the stories file, set the typical fixture as the default args on the meta object, and override args in each story that needs a different state, such as an empty list or a 200-item list. Use loaders only when the data genuinely has to be fetched asynchronously before the story renders.
What states should a Storybook component story cover?
At minimum an empty state, a typical state, a many state with enough
items to trigger scrolling, pagination, or truncation, and an extreme
state with long names, long unbroken strings, null optional fields, and
very large numbers. Loading and error states usually come from component
props such as isLoading or error rather than from the data, so give
them their own stories as well.
Should Storybook stories fetch mock data from an API?
Not for stories you snapshot. Generate the fixtures ahead of time with a script, commit the JSON files, and import them, so CI snapshot runs have no network dependency and no API key. A live-fetch loader is fine for exploring a component locally, as long as the API key stays out of the browser bundle and the story is excluded from snapshot runs.
How do I keep visual regression tests from failing because of random data?
Generate the data from a fixed seed and commit the output, so every snapshot run renders identical values. Serve images such as avatars from local static files instead of remote URLs, and freeze the clock for anything that renders relative time. Then a snapshot diff means the UI changed, or someone deliberately regenerated the fixtures in the same pull request.
How do I mock dates in Storybook so relative times don't change?
Either pass the current time into the component as a prop and give
stories a fixed value, or freeze Date in the story setup, for example
with the mockdate package in a beforeEach hook (Storybook 8.2+).
Fixed fixture dates aren't enough on their own, because a
label like "2 days ago" is computed from the real current time on every
run.
Is there a JsonFabrica addon for Storybook or Chromatic?
No. JsonFabrica is an HTTP API that returns generated JSON, and it also has an MCP server for AI coding assistants. It has no Storybook addon, no npm package for Storybook, and no Chromatic or Percy integration. Your own script calls the API and writes the JSON files that your stories import.
One parameterized template, four seeded calls, and four committed files
give every component state a story whose snapshot only changes when the
UI does. Template generation with params and a fixed seed is part of
the JsonFabrica API. The
templates API reference covers the
request and response.
Generate realistic test data with JsonFabrica
Describe the shape of your data once, then generate as many fresh, realistic JSON documents as you need via a simple API call.