Storybook.JS with Shadow-CLJS

shaolang

shaolang

Posted on February 14, 2021

Storybook.JS with Shadow-CLJS

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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:

;; 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}}}}}
Enter fullscreen mode Exit fullscreen mode

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:

(ns acme.core
  (:require [reagent.dom :refer [render]]))

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

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

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:

<!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>
Enter fullscreen mode Exit fullscreen mode

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:

;; 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}}}}}
Enter fullscreen mode Exit fullscreen mode

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

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:

(ns acme.components.header)

(defn header [text]
  [:h1 text])
Enter fullscreen mode Exit fullscreen mode

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

(ns acme.core
  (:require [acme.components.header :refer [header]]
            [reagent.dom :refer [render]]))

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

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:

module.exports = {
  stories: ['../public/js/stories/**/*_stories.js'],
};
Enter fullscreen mode Exit fullscreen mode

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

;; 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"}}}
Enter fullscreen mode Exit fullscreen mode

A few notable points on the new :stories profile:

  • :entries specifies the namespaces to transpile to stories; unlike :frontend profile that specifies the target filename to output to (main.js), Shadow-CLJS uses the namespace as the output filename, e.g., acme.stories.header_stories.js
  • :target states the build should target npm module which works for Storybook.JS2

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

"scripts": {
  "dev": "shadow-cljs watch frontend",
  "dev-stories": "shadow-cljs watch stories",
  "storybook": "start-storybook"
}
Enter fullscreen mode Exit fullscreen mode

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

(ns acme.stories.header-stories
  (:require [acme.components.header :refer [header]]
            [reagent.core :as r]))

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

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

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:

(defn ^:export GoodbyeSekaiHeader []
  (r/as-element [header "Goodbye, Sekai!"]))
Enter fullscreen mode Exit fullscreen mode

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. 

💖 💪 🙅 🚩
shaolang
shaolang

Posted on February 14, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Storybook.JS with Shadow-CLJS
clojurescript Storybook.JS with Shadow-CLJS

February 14, 2021