Running JavaScript with Templates in Storybook for HTML

Illustration of lines representing programming language on a black background
When working with Storybook for HTML, it can be difficult to pair a template with a JavaScript file. Here we explain a solution that has proven extremely effective for us at NewCity.

At NewCity, most of our markup is written in Twig. We do use JavaScript components where appropriate—for example, we used React for the search portal of Harvard’s Long 19th Amendment Project, and Svelte for the Washington Institute’s Yemen Matrix—but many of our projects are traditional CMS sites that call for templates.

For a few years now, we’ve also been using Storybook (for HTML) to create our pattern libraries. Among pattern-library tools we’ve tried, Storybook stands out as the most flexible and powerful by far, largely because it lets us manage things with JavaScript instead of just static YML or JSON files. Ironically, it also stands out as the one that makes it most difficult to pair a template with a JavaScript file that should run with it in the browser.

In this article, I’ll briefly explain the source of the template-and-JavaScript Storybook problem, and then I’ll detail our solution to it. I won’t shy away from the technical stuff, so buckle up! I should say at the outset that our solution relies on Webpack; I’m not sure if it would be portable to Storybook for Vite.

I’ve put together a GitHub repository that shows our solution in action. It’s a much simplified version of the setups we use, but it does most of what’s described in this post and a bit more.

The Problem

The main reason it’s difficult to get Storybook to pair a template with a JavaScript file is that Storybook doesn’t reload the page when you navigate to another story; it doesn’t even reload the iframe that it displays your stories in! Rather, it just updates the #root div (in the iframe) with new content. This makes the pattern library quite snappy after it’s initially loaded, but it also means that <script>s in the iframe’s <head> don’t execute upon switching stories, so registering “pattern-level scripts” like that generally won’t do.

Another idea that might occur to you is to import the script you’re trying to run into the *.stories.js file corresponding to the template in question, like this:

				
					<xmp>// Teaser.stories.js
import TeaserTemplate from './Teaser.twig';
import './Teaser.js';
// ...</xmp>
				
			

But that won't do, either—Storybook will only run each *.stories.js file once, on page-load. And besides, even if Storybook did run Teaser.stories.js every time you navigated to a Teaser story, then it would only run Teaser.js, and not, say, Button.js, which would also be required if the teaser included a button with JavaScript needs.

The Objective

What we really want, then, is for each pattern-level script to run (just once!) if the template it goes with has been used to render any of the fresh content in the #root div upon changing stories. That's going to require:

  1. watching #root for changes with a MutationObserver;
  2. giving each template an identifying "flag" in its markup to make it possible to iterate through #root's descendants in the DOM and generate a list of templates currently in use;
  3. making each pattern-level script "executable" in the browser as needed, and having a way of associating each script with the template it goes with.

Let's throw in a couple of stretch goals:

  1. We'd rather not have to manually give the templates their identifying flags. In fact, if possible, the identifying flags shouldn't even be hard-coded into the template files, because we don't want them showing up in the DOM on the actual website. We'd like these flags to be Storybook-only.
  2. Our system of storing pattern-level scripts as "executables" (read: functions) that run when needed should be able to handle changes to the scripts without requiring reloading the page. Ideally that includes the deletion of scripts and the creation of new ones. (This is just to improve the developer experience.)

The Solution

Naming and Filing

We'll start with the easy part: a way of associating each script with its template.

I do this by always keeping a pattern-level script in the same directory as its companion template, and ensuring that script and template share a name (minus the extension). So in my components/ directory, I might have a folder called Button, and in components/Button there's a Button.twig file (the template), a Button.js file (the script that accompanies it), and a Button.stories.js file that I mention now only to remind you that it's there and not to be confused with Button.js.

The naming-and-filing rule you choose doesn't really matter, but you'll need a consistent convention.

Making Your Scripts "Executable" (and Undoing It Later)

Pattern-level scripts that you'd register in a CMS aren't typically modules (i.e., they don't import or export anything). And actually, trying to run a module <script> in the browser will throw an error unless you explicitly give it the type="module" attribute.

This poses a bit of a problem, because to make your scripts "executable" in Storybook, you'll have to make them modules that export all their code as a function, like this:

				
					<xmp>// Button.js
const setUpButton = () => {
  // all of the code here
};

// so that code runs on the website (but NOT on Storybook page-load)
if (!window.IS_STORYBOOK) setUpButton();

// for Storybook (needs to be removed for CMS-version of file)
export default setUpButton;</xmp>
				
			

(In a later step, we'll make sure that window.IS_STORYBOOK is set to true in Storybook, though by the time you're reading this Storybook may do this automatically.)

With this formula, the script is ready for Storybook, and you'll only have to remove the export statement at the bottom to make the script CMS-ready. That last part can be done manually, or it can be done programmatically with Babel and a plugin like babel-plugin-transform-remove-export, but it must be done if your CMS isn't loading the scripts as modules. The Babel approach is nice, especially if you can work it into an automated pipeline, and while you're at it you might consider minifying the CMS-version of each script with babel-preset-minify and wrapping it in an IIFE with babel-plugin-iife-wrap. But back to Storybook.

Giving Your Templates Identifying Flags

To programmatically give your templates identifying flags in Storybook, I recommend a simple custom Webpack loader:

				
					<xmp>// .storybook/inject-relative-path-html-comment-loader.js
const path = require('path');

module.exports = function (source) {
  const relativePath = '/' + path.relative(this.rootContext, this.resourcePath);
  return `<!-- START: ${relativePath} -->\n${source}\n<!-- END: ${relativePath} -->`;
};</xmp>
				
			

Any source-file that this loader is applied to will, in the Webpack-server environment only, be sandwiched between HTML comments that specify the file's path relative to the project's root (like <!-- START: /components/Button/Button.twig --> and <!-- END: /components/Button/Button.twig -->). Really, this would be a worthwhile thing to do to your templates for its own sake, as it enhances the usefulness of the browser's dev-tools in Storybook—at a glance, you can tell exactly what file a particular piece of the DOM comes from.

Before I show you how to apply the loader to your template-files, a word of caution: this is a blunt approach, and depending on the rules of your templating language it can result in some errors that you'll have to debug. For example, in Twig (and in Twing, which is the JavaScript port of Twig that I prefer to use with Storybook), any template that extends another is not allowed to contain any content outside of blocks, and that includes HTML comments. For that reason, I generally avoid using extends, and I modify the loader like this:

				
					<xmp>// .storybook/inject-relative-path-html-comment-loader.js
const path = require('path');

module.exports = function (source) {
  const relativePath = '/' + path.relative(this.rootContext, this.resourcePath);

  if (source.trimStart().startsWith('{% extends ')) {
    return `{% block _startExtends %}<!-- START: ${relativePath} -->{% endblock %}\n${source}\n{% block _endExtends %}<!-- END: ${relativePath} -->{% endblock %}`;
  } else {
    return `<!-- START: ${relativePath} -->\n${source}\n<!-- END: ${relativePath} -->`;
  }
};</xmp>
				
			

(And I have to remember to make {% extends the first thing in an extending template, which it should be anyway.) That prevents the error, but if I want the extending template’s identifying HTML comment to actually appear in the DOM, then I have to make sure that the template it extends has an empty {% block _startExtends %}{% endblock %} before any of its HTML content and an empty {% block _endExtends %}{% endblock %} at the end. A bit of a hassle, and maybe it would be cleaner to refactor the loader so that it skips the extending templates altogether (making it impossible for them to have accompanying scripts). Regardless, the point is that the rules of the template engine can complicate things here.

To apply your custom loader to your templates, you need to modify your Webpack config in .storybook/main.js. With Twing, I have:

				
					<xmp>// .storybook/main.js
const path = require('path');

module.exports = {
  // ...

  webpackFinal: async (config) => {
    config.module.rules.push({
      test: /\.twig/,
      use: [
        {
          loader: 'twing-loader',
          options: {
            environmentModulePath: path.resolve(
              __dirname,
              'twing-environment.js'
            ),
          },
        },
        {
          loader: path.resolve(
            __dirname,
            'inject-relative-path-html-comments-loader.js'
          ),
        },
      ],
      include: path.resolve(__dirname, '..', 'components'),
    });

    // ...
    return config;
  },
};</xmp>
				
			

The Script-Runner

We've got our templates set to render their root-relative paths as HTML comments, we've got our pattern-level scripts default-exporting their code as functions, and we've got a filing-and-naming convention that will allow us to pick out a script (if it exists) based on the root-relative path of a template. Now we're ready to write the code that will store those default-exported functions and execute them as needed in response to changes to the #root div. This part isn't easy, but it's kind of awesome. We're going to tap into Webpack's Hot Module Replacement API, which Storybook is already using under the hood.

Here is the essential code (assuming the templates are Twig files), which we'll run once on page-load by importing it into .storybook/preview.js:

				
					<xmp>// .storybook/script-runner.js

// only needed if using Storybook's `storyStoreV7` option (not sure why)
if (module.hot) {
  module.hot.decline();
}

window.IS_STORYBOOK = true;

const root = document.querySelector('#root');

// on page-load, make a webpack 'context' for all script-modules
const scriptImports = require.context(
  '/components',
  true,
  /(?<!stories)\.(?:j|t)s$/
);

// stores script-modules as values
const scriptModules = new Map(
  scriptImports.keys().map((key) => [key, scriptImports(key)])
);

if (module.hot) {
  module.hot.accept(scriptImports.id, () => {
    /*
      This callback will run whenever a pattern-level script has changed.
      This is where we make sure that our mechanism can handle such changes
      without forcing the developer to do a page-reload in the browser.
    */

    // make a fresh webpack 'context' for script-modules (cf. `scriptImports`)
    const freshScriptImports = require.context(
      '/components',
      true,
      /(?<!stories)\.(?:j|t)s$/
    );

    // make a fresh store of script-modules (cf. `scriptModules`)
    const freshScriptModules = new Map(
      freshScriptImports.keys().map((key) => [key, freshScriptImports(key)])
    );

    // if a script has been deleted, remove it from the `scriptModules` cache
    for (const [key] of scriptModules) {
      if (!freshScriptModules.has(key)) scriptModules.delete(key);
    }

    /*
      Next we'll loop through the script-modules in case one is changed or new.
      If we find one that has a matching template whose markup is *already*
      on the page, then we'll force the story to re-render, because when the
      new/updated script runs we want it to run on FRESH markup (and we don't
      want to have to reload the page to make that happen). To force the
      story to re-render, we'll "invalidate" the module corresponding to
      the template in question.

      But first we'll need a webpack "context" for all the template-modules.
    */

    // make a webpack "context" for all the template-modules
    const twigImports = require.context('/components', true, /\.twig$/);

    // we'll also need an array of the context's keys
    const twigKeys = twigImports.keys();

    // make sure that invalidating a template-module will force a re-render
    module.hot.accept(twigImports.id);

    // loop through fresh script-modules in case one is changed or new
    for (const [key, scriptModule] of freshScriptModules) {
      // weed out unchanged ones
      if (scriptModules.get(key) === scriptModule) continue;

      // found new/changed script, so set it in `scriptModules` cache
      scriptModules.set(key, scriptModule);

      // we only care about modules that default-export a function
      if (typeof scriptModule.default !== 'function') continue;

      const fn = scriptModule.default;

      // use our filing-and-naming scheme to get candidate webpack-key
      const candidateTwigKey = key.replace(/(?:j|t)s$/, 'twig');

      // look for a template-module with a matching webpack-key
      const twigKey = twigKeys.find((e) => e === candidateTwigKey);

      // if there's no match, there's nothing to do
      if (!twigKey) continue;

      /*
        Webpack key will be template's path relative to `components` directory,
        but starting with a period (like `./Button/Button.twig`).
        We'll need the string *without* that period to match it against
        strings like `/components/Button/Button.twig` in our HTML comments.
      */
      const twigPath = twigKey.slice(1);

      /*
        Now loop through the HTML comments in `#root`, looking for
        one of our `<!-- START /components/...` flags that matches `twigPath`.
        If we find one, invalidate that Twig module, and we're done here.
      */
      const nodeIterator = document.createNodeIterator(
        root,
        NodeFilter.SHOW_COMMENT
      );

      let currentNode;
      while ((currentNode = nodeIterator.nextNode())) {
        if (currentNode.nodeValue?.trim().endsWith(twigPath)) {
          // found it!

          console.log(
            `[STORYBOOK TWIG SCRIPT-RUNNER] hot-replacing ${twigPath} to execute ${
              fn.name || 'anonymous function'
            } on fresh markup`
          );

          // invalidate the Twig module, forcing a re-render
          require.cache[twigImports.resolve(twigKey)]?.hot.invalidate();
          break;
        }
      }
    }
  });
}

// this MutationObserver runs the needed scripts when `#root` has changed
new MutationObserver(() => {
  /*
    We'll loop through the HTML comments, and keep a list of all
    the template relative-path "flags" we encounter (but we'll replace
    `/components` at the beginning of the string with a period, so that
    it's in its webpack-key form).

    Then for each one we found, we'll look for a companion script-function
    (in our `scriptModules` cache) and execute it if it exists.
  */

  /*
    For keeping track of the webpack-keys for the template-flags we find.
    We use a Set to prevent duplicates (so we'll only run each function once).
  */
  const twigKeys = new Set();

  const commentPrefix = 'START: /components';

  const nodeIterator = document.createNodeIterator(
    root,
    NodeFilter.SHOW_COMMENT
  );

  let currentNode;
  while ((currentNode = nodeIterator.nextNode())) {
    const value = currentNode.nodeValue?.trim();
    if (value?.startsWith(commentPrefix)) {
      twigKeys.add(value.replace(RegExp(`^${commentPrefix}`), '.'));
    }
  }

  // for each twigKey we found, run the corresponding function (if it exists)
  for (const twigKey of twigKeys) {
    const scriptModuleKeyJs = twigKey.replace(/twig$/, 'js');
    const scriptModuleKeyTs = twigKey.replace(/twig$/, 'ts');
    const scriptModule =
      scriptModules.get(scriptModuleKeyJs) ||
      scriptModules.get(scriptModuleKeyTs);

    const fn = scriptModule?.default;

    if (typeof fn === 'function') {
      console.log(
        `[STORYBOOK TWIG SCRIPT-RUNNER] executing ${
          fn.name || 'anonymous function'
        } for ${twigKey}`
      );
      fn();
    }
  }
}).observe(root, { childList: true, subtree: false });</xmp>
				
			

And then in .storybook/preview.js:

				
					<xmp>import './script-runner';</xmp>
				
			

It’s a lot of code! I hope the comments I included are helpful for making sense of it, but to summarize:

  1. We use Webpack’s HMR to maintain a live store of the script-module functions.
  2. We use a MutationObserver to respond to changes to #root by looping through the HTML comment “flags” that our templates left and executing the needed functions (relying on our filing-and-naming convention to make the template-to-script associations).
  3. We respond to a changed or added script by forcing the story to re-render if the “flag” for the script’s associated template is already in the DOM (so that the function will run on fresh markup).

Oh, and the code will accommodate pattern-level scripts with the .ts extension, too, in case you prefer to work in TypeScript (Storybook will handle that just fine out of the box, though you’ll of course need to compile your TypeScript files to JavaScript for the CMS).

By the way, if you’re wondering why I didn’t store '/components' as a variable and instead wrote out the string-literal in several require.context() calls, it’s because require.context() is special, and the string you feed it cannot be a variable (unless you’ve defined it in the Webpack configuration).

Improving the Script-Runner

What we’ve got is great and by itself will suffice most of the time, but there are some edge-cases to think about. How do we handle third-party scripts? And what do we do about stuff that scripts (our own or third-party ones) might do to the DOM above the #root div (say, on body, document, or window)? Remember: neither the page nor the iframe reloads on story-change, and all our script-runner does is re-execute our own scripts as needed; it doesn’t re-execute third-party scripts, and above-#root stuff will linger and may well cause problems.

Unfortunately, there’s no one-size-fits-all solution to the various possibilities. That said, there are some steps we can take to make the script-handler more resilient in general. Let’s start with those, and then discuss third-party scripts.

Here’s a neat trick: we can use a Proxy to “hijack” EventTarget.addEventListener() and keep track of all event-listeners added with that method. We can then remove the event-listeners (or a subset of them) as part of the MutationObserver‘s callback function. Won’t help with inline listeners added above #root, but it’ll take care of things like window.addEventListener('scroll', /* ... */) and document.addEventListener('keydown', /* ... */):

				
					<xmp>// we'll exclude event-listeners added to elements in the Storybook Docs tab
const docsRoot = document.querySelector('#docs-root');

// to maintain a list of event-listener-removers
const removers = new Set();

EventTarget.prototype.addEventListener = new Proxy(
  EventTarget.prototype.addEventListener, {
    apply(addEventListener, eventTarget, args) {
      if (eventTarget instanceof Element && !docsRoot.contains(eventTarget)) {
        removers.add(() => eventTarget.removeEventListener(...args));
      }
      return addEventListener.apply(eventTarget, args);
    },
  }
);

new MutationObserver(() => {
  // when `#root` changes, remove all the event-listeners (and clear the list)
  removers.forEach((removeListener) => removeListener());
  removers.clear();

  // if applicable, maybe do the same for jQuery listeners
  window.jQuery?.('*').off();

  const twigKeys = new Set();

  // ...
}).observe(root, { childList: true, subtree: false });</xmp>
				
			

This approach can be a little dangerous: what if we accidentally remove an event-listener that some Storybook add-on was relying on? If that happens, we can track down the element whose event-listener shouldn't be removed and add an exception for it to the Proxy logic, like we've already done for the Docs tab.

We can similarly maintain a list of ResizeObservers and MutationObservers that we use in our pattern-level scripts, and disconnect them on story-change, though I think in this case we'll have to add the observers to the list manually as part of the pattern-level scripts. So:

				
					<xmp>/*
  For keeping track of `MutationObserver`s and `ResizeObserver`s used in
  our pattern-level scripts. (We'll just manually add the observers to
  this Set as part of those scripts, using an `if (window.IS_STORYBOOK)`
  conditional.)
*/
window.observers = new Set();

const docsRoot = document.querySelector('#docs-root');
const removers = new Set();

EventTarget.prototype.addEventListener = new Proxy(/* ... */);

new MutationObserver(() => {
  removers.forEach((removeListener) => removeListener());
  removers.clear();

  window.jQuery?.('*').off();

  // `disconnect` all observers and clear the list
  window.observers.forEach((observer) => observer.disconnect());
  window.observers.clear();

  const twigKeys = new Set();

  // ...
}).observe(root, { childList: true, subtree: false });</xmp>
				
			

And then if we use a MutationObserver or ResizeObserver in a pattern-level script, we'll just want to remember to do something like:

				
					<xmp>// components/Button/Button.js
const setUpButton = () => {
  // ...
  const observer = new MutationObserver(/* ... */);
  // ...
  if (window.IS_STORYBOOK) {
    window.observers.add(observer);
  }
};
if (!window.IS_STORYBOOK) setUpButton();
export default setUpButton;
</xmp>
				
			

All of that will make our script-runner more resilient to problems caused by above-#root funny business. If other such problems arise, we can try to tackle them in similar ways, though that might not always be possible. Worst-case scenario is that a page-reload is needed here and there.

Dealing with third-party scripts requires care. First, scripts you add to .storybook/preview-head.html that any pattern-level scripts depend on should not include the defer or async attribute; otherwise, the pattern-level scripts that need them may run before they initialize!

The rest is a game of whack-a-mole. For some third-party scripts, adding them to .storybook/preview-head.html is enough. That's the case with jQuery, for example, though you might want to do the window.jQuery?.('*').off() thing demonstrated above.

But many third-party scripts are "one-and-done" deals that become useless after they run their code the first time. Such scripts will need to be dynamically reloaded from the pattern-level scripts that need them, but that should only happen conditionally (because you don't want the pattern-level scripts doing it unnecessarily on the actual website). And to set the "needs-a-reload" condition, you'll want to add some code to the MutationObserver watching #root.

An example is instructive. Say you've got a pattern-level script that makes use of YouTube's IFrame Player API. This is a third-party script that, as far as I can tell, becomes completely useless after you've used it once to initialize your video. That being the case, you'll want to "delete" it upon story-change, and have your pattern-level script reload it when needed. How do you delete it? Well, one way that would probably work is to simply remove its <script> tag from the DOM, and then have your pattern-level script check for the presence of that <script> tag. That might be the best way, and I've had success with it, but with the YouTube library I always just do window.YT = null in the MutationObserver, and then have the pattern-level script check for window.YT === null. So in script-runner.js:

				
					<xmp>new MutationObserver(() => {
  // ...
  window.YT = null;
  // ...
}).observe(root, { childList: true, subtree: false });</xmp>
				
			

And in the pattern-level script that needs the YouTube library:

				
					<xmp>// components/HomepageHeader/HomepageHeader.js
const setUpHomepageHeader = () => {
  // ...
  if (window.YT === null) {
    const script = document.createElement('script');
    script.src = 'https://www.youtube.com/iframe_api';
    document.head.appendChild(script);
  }

  // YouTube library knows to run this function upon initializing
  window.onYouTubeIframeAPIReady = () => { /* ... */ };
  // ...
};
if (!window.IS_STORYBOOK) setUpHomepageHeader();
export default setUpHomepageHeader;</xmp>
				
			

Note that when the pattern-level script reloads the YouTube script this way in Storybook, it should just grab it from the browser's cache, so although this mechanism is somewhat inconvenient, it shouldn't cause a bunch of unnecessary network activity.

Incidentally, delete window.YT does not work as a substitute for window.YT = null. YouTube makes that YT property "non-configurable," and that means that it cannot be deleted from window (running Object.getOwnPropertyDescriptor(window, 'YT') provides that information).

The YouTube example demonstrates a general principle: third-party scripts that must be reloaded to become effective again need to be somehow "deleted" in the MutationObserver and then conditionally re-inserted into the <head> by the pattern-level script that depends on it. The details of how that's accomplished differ from case to case, but this is the game.

Finally—and this relates back to the above-#root business—if a third-party script leaves an element in the DOM outside of #root (like a modal), you'll need to get rid of that in the MutationObserver, too. And even that might not be enough, if the script has surreptitiously done something to window, document, or body that you just can't figure out how to "undo." At some point, good enough is good enough, and you make peace with instructing your users to reload the page if needed for this or that story.

Closing Thoughts

This way of handling JavaScript with a template-based Storybook setup has proven extremely effective for us at NewCity. I've thought about trying to turn it into a bona fide Storybook add-on, but it's got a lot of moving parts, particularities that depend on things like folder-structure and naming conventions, and also those case-by-case tweaks that third-party scripts demand, so I'm not sure whether that's a realistic goal. In any case, I hope some developers out there find it useful!

NewCity logo