/ Bit by bit / posts

Storybook.JS with Shadow-CLJS

Feb 14, 2021

Storybook.JS is a very interesting development tool from JavaScript ecosystem1. This tutorial shows how we can use it with Shadow-CLJS. The code resides at storybook.js-with-shadow-cljs repo.

Prerequisites

The tutorial uses the following:

Make sure the first two are installed prior the tutorial. The others will be installed along the way.

Getting a simple React app running

Let’s create the scaffold to kick-start:

$ mkdir acme
$ cd acme
$ npm init        # just keep pressing enter until the prompt ends
$ npm install --save-dev shadow-cljs

In the generated package.json, add a helper script to launch shadow-cljs and automatically compile when it detect changes:

"scripts": {
  "dev": "shadow-cljs watch frontend"
}

The script uses the :frontend profile defined in shadow-clj.edn for ClojureScript compiler. Run npx shadow-cljs init to generate the skeleton shadow-cljs.edn file and edit it as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
;; shadow-cljs configuration
{:source-paths
  ["src/dev"
   "src/main"
   "src/test"]

 :dependencies
 [[reagent "1.0.0"]]

 :builds
 {:frontend {:target  :browser
             :modules {:main {:init-fn acme.core/init}}}}}

Line 8 adds Reagent as a dependency; lines 11 and 12 create the profile :frontend (that matches the npm script’s shadow-cljs watch command). This profile specifies that the build targets the browser and should generate the file main.js (‘cos of the :main key) that will invoke acme.core/init function at initialization. Let’s implement init that uses a simple Reagent component in src/main/acme/core.cljs:

1
2
3
4
5
6
7
8
9
(ns acme.core
  (:require [reagent.dom :refer [render]]))

(defn header [text]
  [:h1 text])

(defn init []
  (render [header "Hello, World!"]
          (js/document.getElementById "app")))

Simple enough: a custom header component that outputs the given text in an h1 element and the init function that renders the header. To see this glorious app render, create the public/index.html as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!doctype html>
<html>
  <head>
    <meta charset='utf-8'>
    <title>Acme</title>
  </head>
  <body>
    <div id='app'></div>
    <script src='js/main.js'></script>
  </body>
</html>

By default, Shadow-CLJS generates the output to public/js, hence the highlighted line (line 9). When the page is ready, init will run and renders the header component. Before running npm run dev, add dev-http to shadow-cljs.edn to configure the dev-server to listen to port 8080 and serve artifacts from public directory:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
;; shadow-cljs configuration
{:source-paths
  ["src/dev"
   "src/main"
   "src/test"]

 :dev-http {8080 "public"}

 :dependencies
 [[reagent "1.0.0"]]

 :builds
 {:frontend {:target  :browser
             :modules {:main {:init-fn acme.core/init}}}}}

With all these set up, run npm run dev and load the page localhost:8080 in your favorite browser; you should see “Hello, World!":

screenshot of “Hello, World!” rendered in browser

Some cleanup

Before integrating with Storybook.JS, let’s do some cleaning up: extract the custom header component to its own namespace and make acme.core/init use that extracted one instead. First, the extracted component at src/main/acme/components/header.cljs:

1
2
3
4
(ns acme.components.header)

(defn header [text]
  [:h1 text])

Then, in src/main/acme/core.cljs, delete header function and require the header component namespace (as shown in line 2 below):

1
2
3
4
5
6
7
(ns acme.core
  (:require [acme.components.header :refer [header]]
            [reagent.dom :refer [render]]))

(defn init []
  (render [header "Hello, World!"]
          (js/document.getElementById "app")))

Adding Storybook.JS to the mix

Time to add Storybook.JS to the project. Install it with npm install --save-dev @storybook/react; then create .storybook/main.js with the following contents to configure Storybook.JS to look for stories in public/js/stories directory:

1
2
3
module.exports = {
  stories: ['../public/js/stories/**/*_stories.js'],
};

Update shadow-cljs.edn to create a new profile specifically for stories that outputs the transpiled stories to public/js/stories too:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
;; shadow-cljs configuration
{:source-paths
  ["src/dev"
   "src/main"
   "src/stories"
   "src/test"]

 :dev-http {8080 "public"}

 :dependencies
 [[reagent "1.0.0"]]

 :builds
 {:frontend {:target  :browser
             :modules {:main {:init-fn acme.core/init}}}
  :stories  {:target      :npm-module
             :entries     [acme.stories.header-stories]
             :output-dir  "public/js/stories"}}}

A few notable points on the new :stories profile:

Add two script commands to package.json to ease the auto-compilation of stories and to start Storybook.JS:

1
2
3
4
5
"scripts": {
  "dev": "shadow-cljs watch frontend",
  "dev-stories": "shadow-cljs watch stories",
  "storybook": "start-storybook"
}

And finally, the story. Let’ create a very simple story at src/stories/acme/stories/header_stories.cljs that says “Hello, World!":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(ns acme.stories.header-stories
  (:require [acme.components.header :refer [header]]
            [reagent.core :as r]))

(def ^:export default
  #js {:title     "Header Component"
       :component (r/reactify-component header)})

(defn ^:export HelloWorldHeader []
  (r/as-element [header "Hello, World!"]))

The snippet above uses Component Story Format, hence the need to add the metadata ^:export to default and HelloWorldHeader. Because Storybook.JS operates on React components, reactify-component at line 7 turns the Reagent component into a React one.3 With all these preparation, run npm run dev-stories in one console, and npm run storybook in another. You should see Storybook.JS render our first story:

Rendering of Header component in Storybook

For the fun of it, let' append another story to header-stories:

1
2
(defn ^:export GoodbyeSekaiHeader []
  (r/as-element [header "Goodbye, Sekai!"]))

Another rendering of Header component in Storybook

Wrapping up

That concludes this tutorial on using Storybook.JS with Shadow-CLJS. In this case, we are using Reagent to create the components for Storybook.JS to render. It shouldn’t be that difficult to adapt the setup to work with other ClojureScript rendering libraries, e.g., Helix.


  1. ClojureScript world also has a similar devcards↩︎

  2. Shadow-CLJS has a new :esm target that outputs to ES Modules, but as of this writing, it is cumbersome to use (the ^:export metadata hint isn’t working, thus requiring the need to declare all exports in shadow-cljs.edn↩︎

  3. Refer to Reagent’s tutorial on Interop with React for more information. ↩︎