No More Damp Stories

I'm a big fan of Storybook, but it has a DRY[1] problem. You often end up writing the same arguments in multiple places.

Let's say you need search functionality, and want to use it in a few different places on a website. You should standardise how the search is used, so you create a specific design pattern: label text, an icon, the actual input element, focus states, maybe a button or some filter options. In Storybook, you would create a new Search component and set up all of the necessary elements, APIs, etc.  You'll give this a default Story, with all of your arguments defined.

Later, when you come to build out the Header and Footer components, you decide both should have search built in. Of course, you already have your Search component, so you dutifully import it and hook it up, probably extending the Header and Footer component APIs so that they can directly control the search wherever they are used. Great, that component architecture is already paying off! 🎉

But now, you have a problem. You go to create the Story files for the new Header and Footer components, but they also need to set the arguments for the Search component. These are unlikely to be much different from the arguments already set in the Search component's own Story file, where you've already defined the label, icon, colour scheme etc. But now, do you have to do that same thing again for each of the Header and Footer Story files? That's repetition. That's a DRY violation. And whilst I don't believe in being completely dogmatic about principles like DRY, here it has a legitimate ripple effect.

As time progresses, we might want to change the way that the Search component works. Maybe we add some placeholder text. Or a new styling option for use in key areas, like hero sections or modals. Whatever the reason, we've modified our baseline component, updated the Story, and it all looks good. But when we go to check those changes in, our CI checks fail. We're getting critical build failures from the Header and Footer components. Why? We didn't change anything in those files!

Except, we have. We changed a component that they depend upon, but didn't update the information they contain. Eurgh...😒 Annoying!

Of course, I'm writing this article because there is a ✨ better way ✨. You can set up your Stories to share their arguments. If you follow this approach, when you update your Search component's Story file, those changes are automatically applied to your Header and Footer, no additional changes needed.

Sharing Story State

Unfortunately, Storybook does not make things easy. Whilst it is possible to share Stories across different components, the official documentation on how to do this is wrong. If you just search for common phrases like "reuse my Story" or "import a Story" you'll find a page talking stories for multiple components, which seems to offer a solution. Unfortunately, this hasn't worked for years. (And yes, there is a long thread of people complaining about this on an outstanding issue. The thread claims that the problem began with the move to CSF3, though I also can't get any of the proposed solutions to work using the older CSF2 format, either. Why the Storybook team don't just remove this section of the docs is beyond me.)

In fact, right now, you simply cannot meaningfully reuse a Story from another file. But that doesn't mean all hope is lost. Instead of importing entire Stories as if they were components (as you used to be able to in earlier versions of the software), we can piggyback on the Args API.

Using our Search and Footer components (as outlined above), this is how that might work[2]:

// Search.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { Search } from ".";

const meta = {
    title: "Atom/Search",
    component: Search,
    args: {
        label: "Search",
        icon: "magnifying-glass",
        placeholder: "Enter search query...",
    },
} satisfies Meta;

export default meta;
type Story = StoryObj;

export const Default: Story = {};



// Footer.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { Footer} from ".";

import * as SearchStories from "../Search/Search.stories.tsx";
import { SearchType } from "../Search";

const meta = {
    title: "Molecule/Footer",
    component: Footer,
    args: {
        search: SearchStories.default.args as SearchType,
        disclaimer: "Terms & Conditions apply",
        version: "1.1.2",
    },
} satisfies Meta;

export default meta;
type Story = StoryObj;

export const Default: Story = {};

The key is to make sure that you have defined your arguments in the simplest way possible. For that, any common defaults should either be defined on the component itself, or in the meta object at the top of the Story file. You also need to ensure that all Stories (including the meta object) are exported.

We can then import everything from a Story file using something like this:

import * as ComponentNameStories from "../Component/Component.stories";

And reference this from an args object like so:

args: {
   header: ComponentNameStories.default.args as ComponentNameType,
},

Once that's all rigged up, when you update the arguments in one place, all of the components will be updated, no additional editing needed!

And yes, you can reuse any set of Story arguments, it doesn't have to be the default ones. Let's say you have a Layout component which uses the Header and Footer components, so you import their Stories to keep things nice and DRY. But in a specific scenario, you want to show a much reduced Header – maybe as part of some full-screen mode or presentation view. You probably have an option on the Header component to control that variation, and therefore have a corresponding Story applying those settings, called Slimline. You can import that Story and use it in the same way[3]:

args: {
   header: HeaderStories.Slimline.args as HeaderType,
   footer: FooterStories.default.args as FooterType,
},

Or, what if you want 90% of the default arguments passed through, but also want to modify one or two values? That's doable as well. Because the Args API uses a JavaScript Object, we can use spread syntax and overwrite only specific arguments:

args: {
  search: {
     ...SearchStories.default.args as SearchProps,
     label: "Press to Search",
  },
},
 

All of these techniques can be mixed around and used not just on the meta object, but on individual Stories as well. The result is a very powerful pattern which should completely remove any need for argument value duplication across a codebase, and which makes updating components an absolute breeze. I've had components nested in dozens of places that can be updated from a single code change in one file – you'll wonder how you ever used Storybook without it!

Caveat

This may be obvious, but it's worth stating: don't do this for every instance of an embedded component!

For instance, just because you have a Button component with a set of pre-defined Stories, that doesn't mean these are going to be a good fit for every higher-order component that embeds a Button. Chances are, those component will have pretty specific use-cases, and each Button will therefore want to be heavily modified, meaning that the values you pass will largely be unique. Things like labels, icons, colours – these will all be too context-specific to benefit from sharing Stories between these components, and trying to do so will ultimately become a bigger headache. Sometimes, repeating yourself is the best option.

Plus, if you are finding yourself writing the same set of argument values every single time that you embed a specific component, it can be a good sign that your API is either too broad or lacking meaningful defaults. In other words, there might be a better option that solves the problem in an even more DRY way 😉

Explore Other Articles

Further Reading & Sources

Conversation

Want to take part?

Comments are powered by Webmentions; if you know what that means, do your thing 👍

Footnotes

  • <p>Storybook encourages setting the same arguments over and over and over again, but this is a pain to maintain. There is a better, DRYer approach, it's just not well documented.</p>
  • Murray Champernowne.
Article permalink