DEV Community

Cover image for Render Callbacks in Storybook
Joel Stransky
Joel Stransky

Posted on • Updated on

Render Callbacks in Storybook

Storybook.js is flat out amazing. If you're familiar with Rizzo then you know how long people have been trying to solve the living style guide problem. The issue was never "Could we?". It was, "Is all this curating cost effective?". With storybook that's no longer a question and it represents the future of UI development.

But like most new things, a huge number of our projects have a sea of red-tape to get through before they are modern enough. That is, a lot of us still decorate DOM elements rather than generate them and that makes it difficult to leverage the component goodness that is Storybook.

The Problem

Storybook supports HTML component development, but currently does not provide a callback to the story with a reference to the newly rendered DOM element. Additionally, we need access to the Story's meta within that callback. I imagine the thinking is, use a framework if you need javascript and the addons api probably starts to break down if they start handing off control to the dev.

The Goal

What we need is to be able to get a callback with a reference to our DOM element and the Story's meta once it's been rendered. This is particularly needed by jQuery style developers who can't run scripts until elements exist.

The Workaround

This isn't a hack because it leverages supported Storybook features but isn't a solve because the usage is non-standard and could become native some day. This is a collection of what I found in discord messages, github issues and source code so the credit goes to the community.

Decorators to the rescue.

Storybook's Decorators feature serves to allow custom alterations just within storybook and our test suite would still get the pure component. Decorators are an array of functions that just so happen to run at a time where we can use the addon api to listen for the render event and yet can still communicate with our stories.

Armed with this, we can create our callback. I'm choosing to solve this globally but will work anywhere Decorators do.

The aim is to supply our decorator with a selector to find and a function to call with what it found.

Note: I'm skipping addon registration which would be needed for an official addon.

// .storybook/callback-addon/decorators.js
import addons from "@storybook/addons";

export const withRenderCallback = (selector, callback) => {
  return (Story, context) => {
    // your needs from context may vary
    const { args, id, kind, name, ...rest } = context;
    addons.getChannel().once(STORY_RENDERED, (e) => {
      callback(document.querySelector(selector), { args, id, kind, name });
    });
    return Story();
  };
};
Enter fullscreen mode Exit fullscreen mode

Now, from within our Story file we can decorate as needed

// stories/Button.stories.js
import { withRenderCallback } from "./.storybook/callback-addon/decorators.js";
import { Button } from "/button.ts";

export default {
  title: "Example/Button",
  argTypes: {
    onclick: { action: "button clicked" }
  }
}

// create a js component that renders our HTML
const Component = args => {
  return `<a data-story-of="Button" class="btn btn-primary">This is a button</a>`;
}

// create a template for bindings
const Template = (args) => Component(args);

// bind and decorate a story
export const Primary = Template.bind({});
Primary.decorators = [
  withRenderCallback(`[data-story-of="Button"]`, (element, props) => {
      const { onclick } = props.args;
      element.onclick = onclick;
      const btn = new Button($(element));
    }),
];
Enter fullscreen mode Exit fullscreen mode

Getting Knobs to work

If you got this far, it's probably because you want to now control your component. Controls handle updating props but not calling methods on your components. Knobs can do this but manually.

import { button } from "@storybook/addon-knobs";
...
export const Primary = Template.bind({});
Primary.decorators = [
  withRenderCallback(`[data-story-of="Button"]`, (element, props) => {
      const { onclick } = props.args;
      element.onclick = onclick;
      const btn = new Button($(element));
      button(
        "set 'loading'",
        () => {
          btn.setState("loading");
          return false; // prevent further processing
        },
        "group_1"
      );
      button(
        "set 'default'",
        () => {
          btn.setState("default");
          return false;
        },
        "group_1"
      );
    }),
];
Enter fullscreen mode Exit fullscreen mode

And that's it!
This was a very specific demo so keep in mind Decorators can work at multiple levels. Common controls could be set at the Stories level for instance. This method may work in many situations but I would always recommend seeking out officially supported patterns first.

Top comments (0)