Savvi Studio

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

  1. Helper Overview
  2. Meta Helpers
  3. Decorator Helpers
  4. Handler Helpers
  5. Play Function Helpers
  6. 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!


For task-specific implementation details, see the Storybook integration task files in docs/storybook-integration/.