1. Your first variant¶
You want the first useful state on screen quickly. This chapter registers one login-form story, one variant, and enough setup/assertion data to prove the state is what it claims to be. By the end, you will have a state rendering in the Story shell and you will know why it is isolated from every other state.
The smallest useful Story file¶
Start with a stories.cljs namespace. Requiring the app namespaces matters:
their reg-event-*, reg-sub, and reg-view calls have to run before Story can
refer to their ids.
(ns my-app.stories
(:require [re-frame.story :as rf.story]
[my-app.events]
[my-app.subs]
[my-app.views]))
(rf.story/reg-story :story.login
{:doc "The login form and its important states."
:component :my-app.views/login-card
:args {:heading "Sign in"}
:tags #{:dev :docs}
:substrates #{:reagent}})
(rf.story/reg-variant :story.login/idle
{:doc "Fresh form, no input typed, no request in flight."
:setup [[:login/flow [:login/dismiss]]]
:script [[:assert [:rf.assert/state-is :login/flow :idle]]]
:tags #{:dev :docs :test}})
Open your app's #/stories route (the one the install page mounts the shell
on), select /idle, and the form appears on the canvas.
To follow along without an app of your own, run the shipped testbed: from
implementation/, npx shadow-cljs watch :examples/login-form, then open
http://localhost:8043/index.html#/stories. The browser console may log
shadow-cljs watch for build :login-form not running!; that is a harmless
shadow-cljs notice, because shadow-cljs names the build without its namespace,
and hot reload still works. The testbed registers these states as
:story.login-form in tools/story/testbeds/login_form/stories.cljc; this
tutorial uses the shorter :story.login your own app would, so
:story.login/idle here is :story.login-form/idle there.

This is the first payoff: the view is not reimplemented in the story file. The variant names a registered view id and supplies the state needed to render it.
reg-story is the parent¶
The parent story groups variants that share a view and defaults.
The :component value is a view id keyword, not a function. That is one of
Story's important differences from a render-function story format. The function
stays where it belongs, in the app's view registry. The Story body stays data.
The story id is also the navigation structure. :story.login is the parent;
:story.login/idle and :story.login/error are variants under it. There is no
separate title: "Forms/Login/Error" string to keep in sync with the id.
reg-variant is the state¶
The first three fields you will use constantly are :setup, :script, and
:args.
:setup establishes the precondition. These are real event vectors dispatched
through the app's real event pipeline. In the login testbed, [:login/flow
[:login/dismiss]] is a small way to seed the login machine into :idle.
:script is the behaviour or expectation you want Story to run after setup.
The tutorial starts with an assertion step:
:assert is the checkpoint step: it runs the assertion at that exact point in
the script and records the result, pass or fail, before the runner continues.
Later chapters also spell it [:dispatch-sync [:rf.assert/...]], which sends
the same assertion event down the plain dispatch rail and records the same row.
:args supplies view inputs. A variant can override the parent story's args,
and live Controls edits can override both. The precedence chain is:
Most variants start with only :setup and :script; args become important when
you want to explore presentation inputs.
A schema on the view gives you Controls¶
:args are view inputs, so the view is where a valid input is defined. Give the
view a Malli props schema under :rf/props on its registration:
(ns my-app.views
(:require [re-frame.core :as rf]))
(rf/reg-view ^{:rf/props [:map [:heading {:optional true} [:string {:min 1}]]]}
login-card [{:keys [heading]}]
[:section
[:h3 (or heading "Sign in")]
[login-form]])
The story file does not change. Select /idle and look at Controls in the
right-hand rail: Story reads the schema off the variant's :component and
derives a control for each arg, so :heading gets a text field. Clear the field
and the row shows an inline schema: error, with a banner saying the arg
violates the component's schema: an empty heading is not a valid render of this
view. Without a schema, Story can only guess a control from the value, and the
Schema validation panel below Controls reports "no schema registered for the
variant's :component".
Controls follow the schema's shape: :string gives a text field, :int and
:double a number field, :boolean a checkbox, [:enum ...] a select, and
:map, :vector, :set and :tuple nest their children. :rf/props is the
canonical key; a :schema key in the same place also works. Where a derived
control is not the one you want, the story's or variant's :argtypes wins.
Every variant gets a frame¶
This is the rule that makes Story more than a component gallery:
Every variant runs in its own frame.
That frame has its own app-db, queue, subscriptions, trace records,
interceptors, and lifecycle. Selecting /idle does not warm up /error.
Putting five variants in a grid does not give you one app-db being frantically
mutated behind the curtain. It gives you five isolated app instances, each with
the same registered app code and different state.
This is why Story can render application states instead of only component states. The login view can subscribe, dispatch, read machine state, emit effects, and behave like the real app because it is running inside a real frame.
Assertions record results¶
The canonical :rf.assert/* assertions are ordinary events. They record
assertion rows in the variant frame instead of throwing on the first failure.
The common assertions are:
| Assertion | Use it for |
|---|---|
:rf.assert/path-equals |
checking a path in app-db. |
:rf.assert/path-matches |
checking a path against a schema. |
:rf.assert/sub-equals |
checking a real subscription value. |
:rf.assert/dispatched? |
checking that a script dispatched an event. |
:rf.assert/state-is |
checking a registered machine's state. |
:rf.assert/no-warnings |
checking the run emitted no warnings. |
:rf.assert/effect-emitted |
checking that an effect id was emitted. |
The record-don't-throw rule is boring until a failure happens, and then it is lovely. A variant can collect multiple failures in one run, keep the shell alive, and hand you the full set of facts instead of one stack trace and a half-run scenario.
The first troubleshooting loop¶
If the sidebar is empty, the stories namespace probably was not required by the dev entry point.
If the canvas says the component cannot be resolved, check that :component
matches a registered view id.
If Test mode says no assertions were recorded, check that the variant's
:script actually carries an assertion step, for example:
You now have one named state. The next problem is the usual one: real UIs do not have one state. They have a whole little family of them, and at least one is waiting to embarrass you in a demo.