Skip to main content
Portable stories let you reuse your Storybook stories in external testing environments like Jest. This eliminates duplication between your stories and tests while ensuring your components work exactly as shown in Storybook.

Why Portable Stories?

Portable stories solve a common problem: maintaining component examples in two places—once in Storybook and again in tests. With portable stories, you write stories once and reuse them in tests. Benefits:
  • Single source of truth: Stories define component states, tests verify behavior
  • Better coverage: Test the exact scenarios shown in Storybook
  • Less maintenance: Update stories in one place
  • Consistent setup: Args, decorators, and parameters apply automatically

Quick Example

Here’s how portable stories work in practice:
import type { Meta, StoryObj } from '@storybook/react-native';
import { fn } from 'storybook/test';
import { Button } from './Button';

const meta = {
  component: Button,
  args: {
    onPress: fn(),
  },
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary: Story = {
  args: {
    title: 'Sign In',
  },
};

export const Secondary: Story = {
  args: {
    title: 'Create Account',
    variant: 'secondary',
  },
};

composeStories

The composeStories function processes all stories from a story file and returns them as testable components.

Type Signature

(
  csfExports: CSF file exports,
  projectAnnotations?: ProjectAnnotations
) => Record<string, ComposedStoryFn>

Basic Usage

import { test, expect } from '@jest/globals';
import { render, screen } from '@testing-library/react-native';
import { composeStories } from '@storybook/react';
import * as stories from './Button.stories';

// Every export maps 1:1 with stories from the file
// but includes all annotations from story, meta, and project levels
const { Primary, Secondary } = composeStories(stories);

test('renders primary button with default args', () => {
  render(<Primary />);
  const buttonElement = screen.getByText('Text coming from args in stories file!');
  expect(buttonElement).not.toBeNull();
});

test('renders primary button with overridden props', () => {
  // Override props - they merge with story args
  render(<Primary>Hello world</Primary>);
  const buttonElement = screen.getByText(/Hello world/i);
  expect(buttonElement).not.toBeNull();
});

Parameters

csfExports (required)

The full set of exports from your CSF file. Import with import * as stories:
import * as stories from './Button.stories';
const composedStories = composeStories(stories);
Do NOT import just the default export. Use import * as stories to get all exports including meta and individual stories.

projectAnnotations (optional)

Project-level configuration like global decorators and parameters. Usually set once with setProjectAnnotations instead:
// Typically you don't pass this directly
const { Primary } = composeStories(stories, projectAnnotations);

Return Value

An object where keys are story names and values are composed story components:
const { Primary, Secondary, Loading, Disabled } = composeStories(stories);
Each composed story includes these properties:
PropertyTypeDescription
storyNamestringThe story’s name
argsRecord<string, any>The story’s args
argTypesArgTypeThe story’s argTypes
idstringThe story’s id
parametersRecord<string, any>The story’s parameters

composeStory

Use composeStory to compose a single story instead of all stories from a file.

Type Signature

(
  story: Story export,
  componentAnnotations: Meta,
  projectAnnotations?: ProjectAnnotations,
  exportsName?: string
) => ComposedStoryFn

Basic Usage

import { jest, test, expect } from '@jest/globals';
import { render, screen, userEvent } from '@testing-library/react-native';
import { composeStory } from '@storybook/react';
import meta, { Primary } from './Button.stories';

test('onclick handler is called', async () => {
  // Returns a story with all annotations from story, meta, and global levels
  const PrimaryStory = composeStory(Primary, meta);

  const onPressSpy = jest.fn();

  render(<PrimaryStory onPress={onPressSpy} />);

  const user = userEvent.setup({});
  const actionButton = screen.getByText('Press me!');

  await user.press(actionButton);

  expect(onPressSpy).toHaveBeenCalled();
});

Parameters

story (required)

The individual story to compose:
import { Primary } from './Button.stories';
const PrimaryStory = composeStory(Primary, meta);

componentAnnotations (required)

The meta (default export) from the story file:
import meta, { Primary } from './Button.stories';
const PrimaryStory = composeStory(Primary, meta);

projectAnnotations (optional)

Project-level configuration. Usually set with setProjectAnnotations.

exportsName (optional)

Manually specify the story’s export name for unique identification. Rarely needed:
const PrimaryStory = composeStory(Primary, meta, undefined, 'Primary');

setProjectAnnotations

Call this once before tests run (typically in a Jest setup file) to apply global decorators, parameters, and other project configuration to all composed stories.

Type Signature

(projectAnnotations: ProjectAnnotation | ProjectAnnotation[]) => void

Setup File Example

// setup-portable-stories.ts
import { setProjectAnnotations } from '@storybook/react';
import * as addonAnnotations from 'my-addon/preview';
import * as previewAnnotations from './.rnstorybook/preview';

setProjectAnnotations([previewAnnotations, addonAnnotations]);
Then configure Jest to run this setup file:
// jest.config.js
module.exports = {
  setupFiles: ['./setup-portable-stories.ts'],
};

Addon Decorators and Loaders

Some addons require decorators or loaders to work correctly. For example, a router addon might wrap stories in a navigation context. If the addon automatically applies decorators/loaders, include the addon’s preview annotations:
import * as addonAnnotations from 'my-addon/preview';
setProjectAnnotations([previewAnnotations, addonAnnotations]);
If you manually apply them in preview.tsx (like withThemeFromJSXProvider from @storybook/addon-themes), they’re already included:
// .rnstorybook/preview.tsx
import { withThemeFromJSXProvider } from '@storybook/addon-themes';

const preview = {
  decorators: [withThemeFromJSXProvider({ ... })],
};

export default preview;
No additional setup needed—previewAnnotations already includes them.
Always include your .rnstorybook/preview exports in setProjectAnnotations to ensure tests use the same configuration as Storybook.

Real-World Example

Here’s a complete example from the Storybook React Native repository:
import type { Meta, StoryObj } from '@storybook/react-native';
import { ActionButton } from './Actions';
import { fn } from 'storybook/test';

const meta = {
  component: ActionButton,
  parameters: {
    notes: `
# Button

This is a button component.
You use it like this:

\`\`\`tsx    
<Button 
      text="Press me!" 
      onPress={() => console.log('pressed')} 
/>
\`\`\`
`,
  },
} satisfies Meta<typeof ActionButton>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Basic: Story = {
  args: {
    text: 'Press me!',
    onPress: fn(),
  },
};
This test:
  1. Imports the Basic story via composeStories
  2. Overrides the onPress handler with a Jest spy
  3. Renders the story with all its args and decorators
  4. Simulates a user press event
  5. Verifies the handler was called

Annotations Explained

Annotations are the metadata applied to stories, including:
  • Args: Component props/inputs
  • Decorators: Wrapper components (providers, layouts)
  • Loaders: Async data fetching
  • Parameters: Storybook configuration
Annotations can be defined at three levels:
  1. Project level: .rnstorybook/preview.tsx (applies to all stories)
  2. Component level: Meta/default export (applies to all stories for that component)
  3. Story level: Individual story objects (applies to that story only)
Portable stories compose all three levels automatically:
// Global decorators from preview.tsx
// + Component decorators from meta
// + Story decorators from individual story
// = Fully composed story
const { Primary } = composeStories(stories);

Testing Best Practices

Override Props Selectively

Composed stories accept props that merge with story args:
const { Primary } = composeStories(stories);

// Use default args from story
render(<Primary />);

// Override specific props
render(<Primary onPress={myMockFn} />);

// Override multiple props
render(<Primary title="Custom" loading={true} />);

Test Multiple Stories

Test various component states by composing multiple stories:
const { Primary, Loading, Disabled, Error } = composeStories(stories);

test('primary state', () => {
  render(<Primary />);
  expect(screen.getByText('Sign In')).not.toBeNull();
});

test('loading state', () => {
  render(<Loading />);
  expect(screen.getByTestId('spinner')).not.toBeNull();
});

test('disabled state', () => {
  render(<Disabled />);
  const button = screen.getByText('Sign In');
  expect(button.props.accessibilityState.disabled).toBe(true);
});

test('error state', () => {
  render(<Error />);
  expect(screen.getByText('Error occurred')).not.toBeNull();
});

Verify Interactions

Combine portable stories with React Native Testing Library for interaction testing:
import { render, screen, userEvent, waitFor } from '@testing-library/react-native';

test('form submission', async () => {
  const onSubmit = jest.fn();
  const { LoginForm } = composeStories(stories);
  
  render(<LoginForm onSubmit={onSubmit} />);
  
  const user = userEvent.setup();
  
  await user.type(screen.getByPlaceholderText('Email'), '[email protected]');
  await user.type(screen.getByPlaceholderText('Password'), 'password123');
  await user.press(screen.getByText('Sign In'));
  
  await waitFor(() => {
    expect(onSubmit).toHaveBeenCalledWith({
      email: '[email protected]',
      password: 'password123',
    });
  });
});

Test with Context and Providers

If your stories use decorators for context providers, those are automatically included:
// Story file already has decorators
const meta = {
  component: ThemedButton,
  decorators: [
    (Story) => (
      <ThemeProvider theme={darkTheme}>
        <Story />
      </ThemeProvider>
    ),
  ],
};

// Test automatically includes ThemeProvider
const { Primary } = composeStories(stories);
render(<Primary />);  // Already wrapped with ThemeProvider

Troubleshooting

Story not rendering correctly

Ensure you’ve called setProjectAnnotations with your preview configuration:
// setup-portable-stories.ts
import { setProjectAnnotations } from '@storybook/react';
import * as previewAnnotations from './.rnstorybook/preview';

setProjectAnnotations(previewAnnotations);

Missing decorators

Verify addon decorators are included in setProjectAnnotations:
import * as addonAnnotations from 'my-addon/preview';
setProjectAnnotations([previewAnnotations, addonAnnotations]);

TypeScript errors

Ensure you’re importing the full story module:
// ✅ Correct
import * as stories from './Button.stories';
const { Primary } = composeStories(stories);

// ❌ Wrong
import { Primary } from './Button.stories';
const composed = composeStories(Primary);  // Type error

Integration with Testing Tools

Portable stories work seamlessly with popular React Native testing tools:
  • @testing-library/react-native: Component queries and interactions
  • Jest: Test runner and assertions
  • universal-test-renderer: Cross-platform testing (used internally by Storybook React Native)

Example Test Suite

import { describe, test, expect, jest } from '@jest/globals';
import { render, screen, userEvent } from '@testing-library/react-native';
import { composeStories } from '@storybook/react';
import * as stories from './Button.stories';

const { Primary, Secondary, Loading, Disabled } = composeStories(stories);

describe('Button Component', () => {
  describe('Primary variant', () => {
    test('renders with correct text', () => {
      render(<Primary />);
      expect(screen.getByText('Sign In')).toBeTruthy();
    });

    test('calls onPress when pressed', async () => {
      const onPress = jest.fn();
      render(<Primary onPress={onPress} />);
      
      const user = userEvent.setup();
      await user.press(screen.getByText('Sign In'));
      
      expect(onPress).toHaveBeenCalledTimes(1);
    });
  });

  describe('Loading state', () => {
    test('shows loading indicator', () => {
      render(<Loading />);
      expect(screen.getByTestId('loading-indicator')).toBeTruthy();
    });

    test('disables interaction', () => {
      const onPress = jest.fn();
      render(<Loading onPress={onPress} />);
      
      // Verify button is disabled - exact assertion depends on implementation
      const button = screen.getByTestId('button');
      expect(button.props.accessibilityState?.disabled).toBe(true);
    });
  });

  describe('Disabled state', () => {
    test('prevents onPress', async () => {
      const onPress = jest.fn();
      render(<Disabled onPress={onPress} />);
      
      const user = userEvent.setup();
      const button = screen.getByText('Sign In');
      
      // Attempt to press disabled button
      await user.press(button);
      
      expect(onPress).not.toHaveBeenCalled();
    });
  });
});

Next Steps

Writing Stories

Learn how to write better stories for testing

Addons

Enhance stories with on-device addons

Build docs developers (and LLMs) love