DEV Community

Cover image for Storybook: Stories for Smart Components
Marsel Akhmetshin
Marsel Akhmetshin

Posted on

Storybook: Stories for Smart Components

Note: This article is intended for advanced users familiar with Storybook. I will explain how to create Storybook stories for complex and feature-rich components.

Motivation

The ideal Storybook stories for components are those built around “dumb” components, where you can control the props (properties) through the Storybook interface. This approach works well for component libraries, but what if we need to create a Storybook story for a component that is integrated with application state, fetches data from a router, makes backend requests, and supports multiple languages? At first glance, it might seem impossible to fit such a component into Storybook, but what if I told you that it’s actually very much achievable?

Decorators and Addons Mechanism

Before we dive in, let’s take a look at two Storybook mechanisms that help extend its capabilities.

Decorators
Decorators are simple functions that take a Story as input, meaning the component we want to display in Storybook, and return a modified version of that Story. Inside a decorator, we are expected to make the necessary modifications to the Story and return the result of these changes. If a side effect needs to be executed, the decorator can simply return the original Story that was passed in.

Let’s take a closer look at the decorator below:

// decorators.js
import React from 'react';

export const withBackground = (Story) => (
  <div style={{ padding: '20px', backgroundColor: '#f0f0f0' }}>
    <Story />
  </div>
);

Enter fullscreen mode Exit fullscreen mode

Here we can wrap our story in a div with padding: 20px and backgroundColor: #f0f0f0. This will affect the display of our components wrapped in this decorator in Storybook.

Now, let’s see how it can be applied to our story:

import React from 'react';
import { withBackground } from './decorators';
import { Button } from './Button';

export default {
  title: 'Example/Button',
  component: Button,
  decorators: [withBackground]
};
Enter fullscreen mode Exit fullscreen mode

To apply the decorator specifically to our Button component, we pass our withBackground decorator, which we defined earlier, to the decorators property in its story description.

As a result, when rendering our Button in Storybook, our component will have padding and a background as well.

Earlier, I mentioned "specifically for our component". The fact is that decorators can be both personal and global. This means that we can apply a decorator to all our stories in Storybook.

Let’s look at how we can do this:

//.storybook/preview.js
import React from 'react';
import { withBackground } from './decorators';

const previewConfig = {
  //...
  decorators: [withBackground],
  //...
};

export default previewConfig;
Enter fullscreen mode Exit fullscreen mode

It's pretty simple: open the .storybook/preview.js file and add our withBackground decorator function to the decorators property in the Storybook configuration. After this, all components will automatically be wrapped in this decorator, adding padding and a background.

Addons
Addons in Storybook are extensions that add extra functions and capabilities for working with components in Storybook. They enhance the development and testing process by offering tools for interaction, customization, and visualization of components.
Later in this article, we will look at the main addons that help us display our smart components in Storybook, as well as how to configure them. There is no universal way to configure all addons, so I will explain how to do this using specific addon examples.

Integration of a Story with the Application Store: An Example with Redux

Let’s explore how we can set up state management around our Story using Redux as an example. The decorator mechanism will help us achieve this. Let’s go through it step by step.

Step 1: Define a Simple Reducer

const reducer = (state = {}, action) => {
  switch (action.type) {
    case 'INITIALIZE_STATE':
      return { ...state, ...action.payload };
    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

This is straightforward: we handle an action with the type INITIALIZE_STATE and merge its payload with our current state.

Step 2: Create a Decorator to Wrap the Story with a Redux Context

Next, let’s create a decorator that will wrap our Story in a context and also initialize the state for our component:

import React from 'react';
import { Provider } from 'react-redux';
import { createStore } from 'redux';

export const withReduxState = (initialState) => (Story) => {
  const store = createStore(reducer);

  useEffect(() => {
    store.dispatch({ type: 'INITIALIZE_STATE', payload: initialState });
  }, [initialState]);

  return (
    <Provider store={store}>
      <Story />
    </Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Here’s what’s happening:

  • We create our Redux store using the reducer defined earlier.
  • We then initialize the store with some initial data using store.dispatch and the INITIALIZE_STATE action.
  • Finally, we wrap our component (Story) with the Redux Provider, passing the store as a prop to ensure the component has access to the Redux state.

Step 3: Apply the Decorator in a Story Component

Now, let’s see how we can apply this decorator in a story for a component:

// Button.stories.js
import React from 'react';
import { withReduxState } from './decorators';
import { Button } from './Button';

export default {
  title: 'Example/Button',
  component: Button,
  decorators: [withReduxState(initialState)]
};
Enter fullscreen mode Exit fullscreen mode

This is similar to the example with the background decorator, but with one key difference: withReduxState is a function that returns a decorator with initialState in its scope. This approach is especially useful when you want to create different stories for a single component in various states.

In this example, we’ve demonstrated how to use decorators to integrate the Redux state manager into Storybook. I’m confident that a similar approach can be successfully applied with other state managers, allowing you to easily manage the state of components in various scenarios.

Integration of a Story with React Router

Now let’s consider a situation where the behavior of a component depends on the current URL in the browser's address bar. For example, the component should be displayed in red at location /path1 and in green at location /path2. How can we implement such behavior in Storybook? The storybook-addon-remix-react-router addon will help us achieve this.

Setting Up the Plugin in the Project
First, let’s enable the plugin in our Storybook configuration:

// .storybook/main.ts
export default {
   //...
  addons: ['storybook-addon-remix-react-router'],
  //...
}
Enter fullscreen mode Exit fullscreen mode

To do this, add the name of the addon to the addons property in the .storybook/main.ts file.

Configuring the Plugin in a Story

Now, let’s move on to our UserProfiles.stories.js file and set up virtual routing for our component:

import { withRouter, reactRouterParameters } from 'storybook-addon-remix-react-router';

export default {
  title: 'User Profile',
  component: UserProfile,
  decorators: [withRouter],
  parameters: {
    reactRouter: reactRouterParameters({
      location: {
        pathParams: { userId: '42' },
      },
      routing: { path: '/users/:userId' },
    }),
  },
};
Enter fullscreen mode Exit fullscreen mode

To create a router context around our component, we need to export the component and wrap it in the withRouter decorator. Then, in the story’s configuration, we need to pass the router settings to the parameters property.

It’s important to note that parameters is an object where properties are passed to the addon. Each addon has its own parameter key for accessing its settings, and in our case, it’s parameters.reactRouter.

Let’s take a closer look at the reactRouter settings:

  //...
  parameters: {
    reactRouter: reactRouterParameters({
      location: {
        pathParams: { userId: '42' },
      },
      routing: { path: '/users/:userId' },
    }),
  },
  //...
Enter fullscreen mode Exit fullscreen mode

Using reactRouterParameters({...}), we created a configuration and added the following parameters:

  • location.pathParams.userId — This describes the current location where the component is situated. When the component accesses the router and tries to get the userId parameter, it will receive the value 42.
  • routing.path — This describes the virtual route where our component is located. In the browser, one location will be displayed, but the component will behave as if it is on the route /users/:userId. This allows us to add virtual routes for components whose visual representation depends on routing.

Enabling the Router Globally
We’ve covered setting up a virtual router for a specific component and an individual story. But what if we want to set up a virtual router globally for all stories? This is also possible — just navigate to the .storybook/preview.js file and configure it similarly.

// .storybook/preview.js
export default {
  decorators: [withRouter],
  parameters: {
    reactRouter: reactRouterParameters({ ... }),
  }
}
Enter fullscreen mode Exit fullscreen mode

The principle is the same as in the story settings: define the reactRouter parameter and pass the router configuration to reactRouterParameters({ /* ... */ }).

An attentive reader might ask: why add a new plugin when you could simply implement a decorator that wraps the story in a router? Indeed, that could be done, but there are two advantages to using the plugin.

First, installing the plugin is much faster and simpler than developing your own solution. Second, plugins have an important advantage — they can integrate into the Storybook interface, making them particularly convenient to use:
Image description

The storybook-addon-remix-react-router plugin adds a tab to the Storybook interface where you can see navigation events and the current route of your component.

In this section, we’ve thoroughly covered how to configure a story using virtual routing for your component. This allows you to easily test components that depend on routes. If you want to learn more about the features and settings of this addon, we recommend checking out the detailed documentation available at the following link: storybook-addon-remix-react-router.

Request Mocking

In this section, we will explore how to intercept requests in component stories and respond with the desired data. For this, we'll use the msw-storybook-addon.

Setup
The first thing we need to do is run the following command:

npx msw init public/
Enter fullscreen mode Exit fullscreen mode

Why is this necessary? This command will create a public folder and place a Service Worker there, which will be used to intercept requests.
After loading the Service Worker, you need to configure it to run in Storybook globally. To do this, open the .storybook/preview.js file and follow these steps:

import { initialize, mswLoader } from 'msw-storybook-addon'

initialize()

const preview = {
    //...
  loaders: [mswLoader],
}

export default preview
Enter fullscreen mode Exit fullscreen mode

The initialize function sets up and starts the Service Worker, while mswLoader acts as a loader that waits for the Service Worker to be ready and then applies the request handlers, which we’ll discuss shortly..

💡 Note: .storybook/preview.js is a global config applied to each story individually. This means the config is applied from scratch each time a story is opened.

Now that we’ve configured our addon, let's enable it inside a story:

import { http, HttpResponse } from 'msw'
import { Profile } from './profile'

export const SuccessBehavior = {
    component: Profile
  parameters: {
    msw: {
      handlers: [
        http.get('/user', () => {
          return HttpResponse.json({
            firstName: 'Neil',
            lastName: 'Maverick',
          })
        }),
      ],
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

As mentioned before, each addon has its own parameter through which settings are passed. For msw-storybook-addon, this parameter is parameters.msw. Inside this parameter, you need to specify handlers, which act as request interceptors.
In our example, we intercept a GET request to the /user URL and return a response in JSON format. Similarly, you can handle other types of requests, such as POST, return only an HTTP status, or even simulate a response delay of several seconds.

💡 Note: The msw-storybook-addon is a wrapper around the mswjs library. You can learn more about the capabilities of mswjs here: MSW Documentation.

In this section, we thoroughly explored how to set up request interception in Storybook for your stories using the msw-storybook-addon. This addon allows you to create realistic scenarios for component interactions with APIs, making the testing and development process more efficient. You can configure request handlers for various types of HTTP requests, simulate server responses, and even model response delays. This is particularly useful for testing component behavior under different conditions. For more detailed information and full documentation on this addon, you can visit: msw-storybook-addon.

Switching Story Languages from the Storybook Interface

We’ve already covered the main addons; now let's look at a scenario where you need to switch the component's language directly in the Storybook interface. This method is excellent for most client-side translation solutions.

The first thing we need to do is define our new language switcher, which will be available in the Storybook interface:

export const preview = {
  // ...
  globalTypes: {
    locale: {
      name: "Locale",
      description: "Language",
      toolbar: {
        icon: "globe",
        items: [
          { value: "en", title: "English" },
          { value: "fr", title: "French" },
        ],
      },
    },
  },
  //...
};
Enter fullscreen mode Exit fullscreen mode

Let's break down what's happening here:

  • First, we define a new global variable locale in the globalTypes section.
  • Second, we configure its display settings.

This configuration includes a toolbar, meaning that the switcher will be displayed in the Storybook toolbar. We also specified that the switcher's icon is a globe, and added a list of languages to which the component's story can be switched.
This is what it will look like:
Image description

Now that we’ve set up the switcher in the Storybook interface, let's see how to make the component respond to language changes. A decorator mechanism will help us with this:

const langDecorator = (Story, context) => {
  const locale = context.globals.locale;

  useEffect(() => {
    i18n.changeLanguage(locale);
  }, [locale]);

  return <Story />;
};
Enter fullscreen mode Exit fullscreen mode

Note the second parameter of the decorator function — context. It allows us to access the global variable globals.locale, which stores the currently selected language. We then use this value to pass it, as shown in the example, to i18n or any other translation library to update the interface language.

In this section, we thoroughly explored how to extend the functionality of the Storybook interface by adding support for language switching in components. This approach makes it easy to test and showcase multilingual components by switching languages directly from the Storybook interface. Such a setup makes the development and testing process more convenient and flexible, especially if your project supports multiple languages.

Bonus: Module Federation Support in Storybook

It's hard to imagine a situation where you need to render micro frontends in Storybook, but I will still tell you about an addon that adds such support. This is an excellent example of how powerful addons can be and how they enable even the most atypical tasks in Storybook.
Enabling Module Federation support is quite simple. To do this, you need to install the @module-federation/storybook-addon. Let's start configuring it:

// .storybook/main.ts
export default {
  //...
  addons: [
    {
      name: "@module-federation/storybook-addon",
      options: {
        moduleFederationConfig: moduleFederationConfig(),
      },
    },
  ],
  //...
};
Enter fullscreen mode Exit fullscreen mode

Connecting the addon for Module Federation support is really simple. The moduleFederationConfig needs to be the same configuration used in your webpack config. You can learn more about Module Federation here: Webpack Module Federation.
Documentation and the plugin itself can be found at the following address: @module-federation/storybook-addon.

💡 Note: It may be obvious, but I must mention that for this plugin to work, Storybook needs to be built using webpack.

Conclusion

In this article, I demonstrated how advanced methods can be used to work with more complex components in Storybook that are integrated with application state, routing, and APIs. I explained how decorators and addons help adapt Storybook for testing such components. We covered examples of integration with Redux and React Router, as well as request mocking and language switching. Additionally, I touched on the possibility of supporting micro frontends with Module Federation. In the end, Storybook proves to be a powerful tool that can be configured for a wide range of use cases, making the development and testing process more flexible and efficient.

Top comments (0)