Storybook.JS with Shadow-CLJS
shaolang
Posted on February 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:
- Java version 11
- Node.js version 14.15.4
- Reagent version 1.0.0
- Shadow-CLJS version 2.11.8
- Storybook.JS version 6.1.17
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:
;; 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
:
(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:
<!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:
;; 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!":
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])
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")))
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'],
};
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"}}}
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"
}
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!"]))
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:
For the fun of it, let' append another story to header-stories
:
(defn ^:export GoodbyeSekaiHeader []
(r/as-element [header "Goodbye, Sekai!"]))
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.
-
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 inshadow-cljs.edn
. ↩ -
Refer to Reagent's tutorial on Interop with React for more information. ↩
Posted on February 14, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.