Story Helpers Reference
Last Updated: November 23, 2025
This document covers reusable helper utilities to reduce boilerplate and improve consistency across story files.
Table of Contents
- Helper Overview
- Meta Helpers
- Decorator Helpers
- Handler Helpers
- Play Function Helpers
- Implementation Guide
Helper Overview
Story helpers reduce repetitive code and ensure consistency across story files. They provide clean APIs for common patterns like meta configuration, decorators, and MSW handlers.
Benefits
- ✅ Reduce boilerplate by 40-60%
- ✅ Ensure consistency across stories
- ✅ Type-safe with full TypeScript support
- ✅ Easy to maintain and update
- ✅ Self-documenting code
Meta Helpers
createStoryMeta
Creates standardized story metadata with sensible defaults.
Definition:
// tests/mocks/helpers/storyHelpers.ts
import type { Meta } from '@storybook/react';
export const createStoryMeta = <T,>(
title: string,
component: T,
options?: {
layout?: 'centered' | 'padded' | 'fullscreen';
tags?: string[];
}
): Meta<T> => ({
title,
component,
parameters: {
layout: options?.layout || 'padded',
},
tags: options?.tags || ['autodocs'],
});
Usage:
// Before
const meta = {
title: 'Admin/Setup/TenantForm',
component: TenantForm,
parameters: {
layout: 'padded',
},
tags: ['autodocs'],
} satisfies Meta<typeof TenantForm>;
// After
const meta = createStoryMeta(
'Admin/Setup/TenantForm',
TenantForm
) satisfies Meta<typeof TenantForm>;
With Options:
const meta = createStoryMeta(
'Common/Button',
Button,
{ layout: 'centered', tags: ['autodocs', 'stable'] }
) satisfies Meta<typeof Button>;
Decorator Helpers
withAuth
Wrap story with authenticated user context.
Definition:
import type { Decorator } from '@storybook/react';
import { MockAuthProvider, AuthStates } from '../providers/MockAuthProvider';
export const withAuth = (
authState = AuthStates.authenticated
): Decorator => (Story) => (
<MockAuthProvider {...authState}>
<Story />
</MockAuthProvider>
);
Usage:
// Before
decorators: [
(Story) => (
<MockAuthProvider {...AuthStates.authenticated}>
<Story />
</MockAuthProvider>
),
]
// After
decorators: [withAuth()]
With Custom State:
decorators: [withAuth(AuthStates.admin)]
withAdmin
Wrap story with admin user context.
Definition:
export const withAdmin: Decorator = withAuth(AuthStates.admin);
Usage:
export const AdminOnly: Story = {
decorators: [withAdmin],
};
withNoAuth
Wrap story with unauthenticated state.
Definition:
export const withNoAuth: Decorator = withAuth(AuthStates.unauthenticated);
Usage:
export const LoginPage: Story = {
decorators: [withNoAuth],
};
Handler Helpers
withHandlers
Creates MSW handlers parameter object.
Definition:
export const withHandlers = (...handlers: any[]) => ({
parameters: {
msw: { handlers },
},
});
Usage:
// Before
parameters: {
msw: {
handlers: [
trpcMsw.user.profile.get.query(() => ({ /* ... */ })),
trpcMsw.organization.list.query(() => []),
],
},
}
// After
...withHandlers(
trpcMsw.user.profile.get.query(() => ({ /* ... */ })),
trpcMsw.organization.list.query(() => [])
)
Spread Syntax:
export const WithData: Story = {
...withHandlers(
trpcMsw.user.profile.get.query(() => mockUser)
),
};
Play Function Helpers
createPlayFunction
Creates standardized play function with canvas setup.
Definition:
import { within } from '@storybook/test';
export const createPlayFunction = (
testFn: (canvas: ReturnType<typeof within>) => Promise<void>
) => async ({ canvasElement }: { canvasElement: HTMLElement }) => {
const canvas = within(canvasElement);
await testFn(canvas);
};
Usage:
// Before
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button'));
}
// After
play: createPlayFunction(async (canvas) => {
await userEvent.click(canvas.getByRole('button'));
})
Complex Example:
export const FormSubmit: Story = {
play: createPlayFunction(async (canvas) => {
// Canvas already set up
await userEvent.type(
canvas.getByLabelText(/email/i),
'test@example.com'
);
await userEvent.click(
canvas.getByRole('button', { name: /submit/i })
);
await expect(
canvas.findByText(/success/i)
).resolves.toBeInTheDocument();
}),
};
Implementation Guide
Step 1: Create Helper File
Create tests/mocks/helpers/storyHelpers.ts:
import type { Decorator, Meta } from '@storybook/react';
import { within } from '@storybook/test';
import { MockAuthProvider, AuthStates } from '../providers/MockAuthProvider';
/**
* Create standard story metadata
*/
export const createStoryMeta = <T,>(
title: string,
component: T,
options?: {
layout?: 'centered' | 'padded' | 'fullscreen';
tags?: string[];
}
): Meta<T> => ({
title,
component,
parameters: {
layout: options?.layout || 'padded',
},
tags: options?.tags || ['autodocs'],
});
/**
* Decorator: Wrap story with authenticated user
*/
export const withAuth = (
authState = AuthStates.authenticated
): Decorator => (Story) => (
<MockAuthProvider {...authState}>
<Story />
</MockAuthProvider>
);
/**
* Decorator: Wrap story with admin user
*/
export const withAdmin: Decorator = withAuth(AuthStates.admin);
/**
* Decorator: Wrap story with unauthenticated state
*/
export const withNoAuth: Decorator = withAuth(AuthStates.unauthenticated);
/**
* Helper: Create MSW handlers parameter
*/
export const withHandlers = (...handlers: any[]) => ({
parameters: {
msw: { handlers },
},
});
/**
* Helper: Standard play function setup
*/
export const createPlayFunction = (
testFn: (canvas: ReturnType<typeof within>) => Promise<void>
) => async ({ canvasElement }: { canvasElement: HTMLElement }) => {
const canvas = within(canvasElement);
await testFn(canvas);
};
Step 2: Update Story Files
Before:
import type { Meta, StoryObj } from '@storybook/react';
import { within, userEvent, expect } from '@storybook/test';
import { MockAuthProvider, AuthStates } from '@/tests/mocks/providers/MockAuthProvider';
import { trpcMsw } from '@/tests/mocks/handlers/trpc';
import { LoginForm } from './LoginForm';
const meta = {
title: 'Auth/LoginForm',
component: LoginForm,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta<typeof LoginForm>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
decorators: [
(Story) => (
<MockAuthProvider {...AuthStates.unauthenticated}>
<Story />
</MockAuthProvider>
),
],
parameters: {
msw: {
handlers: [
trpcMsw.auth.login.mutation((input) => ({
success: true,
token: 'mock-token',
})),
],
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByLabelText(/email/i), 'user@test.com');
await userEvent.type(canvas.getByLabelText(/password/i), 'password');
await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
await expect(canvas.findByText(/welcome/i)).resolves.toBeInTheDocument();
},
};
After:
import type { StoryObj } from '@storybook/react';
import { userEvent, expect } from '@storybook/test';
import {
createStoryMeta,
withNoAuth,
withHandlers,
createPlayFunction,
} from '@/tests/mocks/helpers/storyHelpers';
import { trpcMsw } from '@/tests/mocks/handlers/trpc';
import { LoginForm } from './LoginForm';
const meta = createStoryMeta(
'Auth/LoginForm',
LoginForm,
{ layout: 'centered' }
);
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
decorators: [withNoAuth],
...withHandlers(
trpcMsw.auth.login.mutation(() => ({
success: true,
token: 'mock-token',
}))
),
play: createPlayFunction(async (canvas) => {
await userEvent.type(canvas.getByLabelText(/email/i), 'user@test.com');
await userEvent.type(canvas.getByLabelText(/password/i), 'password');
await userEvent.click(canvas.getByRole('button', { name: /sign in/i }));
await expect(canvas.findByText(/welcome/i)).resolves.toBeInTheDocument();
}),
};
Line Reduction:
- Before: 52 lines
- After: 33 lines
- Saved: 19 lines (37% reduction)
Best Practices
1. Import Only What You Need
// ✅ Good - Import only used helpers
import { createStoryMeta, withAuth } from '@/tests/mocks/helpers/storyHelpers';
// ❌ Avoid - Importing everything
import * as helpers from '@/tests/mocks/helpers/storyHelpers';
2. Use Type Safety
// ✅ Good - Type-safe meta
const meta = createStoryMeta(
'Common/Button',
Button
) satisfies Meta<typeof Button>;
// ❌ Avoid - Losing type information
const meta = createStoryMeta('Common/Button', Button);
3. Combine Helpers
// ✅ Good - Use multiple helpers together
export const Protected: Story = {
decorators: [withAuth(AuthStates.admin)],
...withHandlers(
trpcMsw.admin.settings.get.query(() => mockSettings)
),
play: createPlayFunction(async (canvas) => {
// Test code
}),
};
4. Document Custom Usage
// ✅ Good - Document non-standard usage
export const SpecialCase: Story = {
// Using unauthenticated state to test login redirect
decorators: [withNoAuth],
};
5. Keep Helpers Simple
// ✅ Good - Simple, focused helper
export const withAuth = (state = AuthStates.authenticated): Decorator =>
(Story) => (
<MockAuthProvider {...state}>
<Story />
</MockAuthProvider>
);
// ❌ Avoid - Complex helper with too many concerns
export const withEverything = (/* too many params */) => /* ... */;
Common Patterns
Authenticated Component
const meta = createStoryMeta('Features/Dashboard', Dashboard);
export const Default: Story = {
decorators: [withAuth()],
...withHandlers(
trpcMsw.dashboard.data.query(() => mockDashboardData)
),
};
Admin Component
const meta = createStoryMeta('Admin/Settings', AdminSettings);
export const Default: Story = {
decorators: [withAdmin],
...withHandlers(
trpcMsw.admin.settings.get.query(() => mockSettings)
),
};
Public Component
const meta = createStoryMeta('Public/Landing', LandingPage, {
layout: 'fullscreen'
});
export const Default: Story = {
decorators: [withNoAuth],
};
Interactive Test
export const FormSubmission: Story = {
decorators: [withAuth()],
...withHandlers(
trpcMsw.form.submit.mutation(() => ({ success: true }))
),
play: createPlayFunction(async (canvas) => {
await userEvent.type(canvas.getByLabelText(/name/i), 'John Doe');
await userEvent.click(canvas.getByRole('button', { name: /submit/i }));
await expect(canvas.findByText(/success/i)).resolves.toBeInTheDocument();
}),
};
Migration Strategy
1. Start with New Stories
Use helpers in all new story files immediately.
2. Update During Refactoring
When touching existing stories, update to use helpers.
3. Batch Update by Category
Update all auth components, then admin, then public, etc.
4. Validate After Migration
# Ensure stories still work
npm run storybook
Troubleshooting
Helper Not Working
Problem: Helper not found or type errors
Solution:
// Ensure correct import path
import { withAuth } from '@/tests/mocks/helpers/storyHelpers';
// Check TypeScript path mapping in tsconfig.json
Type Inference Issues
Problem: Loss of type safety with helpers
Solution:
// Use `satisfies` for type checking
const meta = createStoryMeta(/* ... */) satisfies Meta<typeof Component>;
Decorator Not Applied
Problem: Decorator helper not wrapping story
Solution:
// Ensure helper returns a Decorator
export const withAuth = (state): Decorator => (Story) => (/* ... */);
// Use in story
decorators: [withAuth()] // Don't forget to call it!
Related Documentation
- Story Structure - Story file structure and configuration
- Testing Patterns - Testing Library and interaction patterns
- MSW Handlers - API mocking patterns
- Decorators - Story decorator patterns
For task-specific implementation details, see the Storybook integration task files in docs/storybook-integration/.