Cookbook¶
Worked recipes, each one a whole thing you can copy into an application and then
edit. Every recipe here is the shape a landed witness in
implementation/hicasso/test/re_frame/hicasso/examples/ already runs, reduced to
the parts a reader needs.
A recipe answers how do I build this. It is deliberately thin on why it works that way, because the chapters own that and repeating them here would give you two answers to keep in step. Each recipe names its chapter.
If you are looking up a signature rather than building something, the API reference is the other half of this pair.
Boot an application¶
Everything below assumes a mounted root, so start here.
(ns my.app
(:require [re-frame.core :as rf]
[re-frame.adapter.uix :as uix-adapter]
[re-frame.hicasso :as h]
[my.app.views :as views]))
(defonce !root (atom nil))
(defn ^:dev/after-load reload!
"Re-render the mounted root after a hot reload."
[]
(when-some [root @!root]
(h/render! root [views/app {}])))
(defn ^:export -main []
(rf/init! uix-adapter/adapter)
(reset! !root
(h/mount! (js/document.getElementById "app")
{:frame :app/main
:initial-events [[:app/seed]
[:rf.route/navigate {:to :route/home}]]}
[views/app {}]))
nil)
Four things about this shape are load-bearing.
rf/init! comes first, and it is not optional. Hicasso is a view layer, not
a substrate: the reactive container app-db lives in
comes from an adapter, and nothing installs one for
you. h/mount! ensures its frame, creating a frame asks the adapter for a state
container, and a mount that beats init! throws
:rf.error/no-adapter-installed. re-frame.adapter.uix is its own artefact —
Installation declares the
day8/re-frame2-uix coordinate it comes from alongside Hicasso's, and
Use UIx or reagent-slim covers the other two
substrates.
Keep the handle. h/render!, h/unmount! and every teardown path take it,
and a reload hook that calls h/mount! a second time would createRoot again —
replacing the tree and discarding every DOM node, subscription and scrap of
component state instead of reconciling against them.
Seed in one place. h/mount! ensures its frame: it creates the frame if
absent and seeds it with :initial-events, or joins it untouched if a root
already made it. So if you call rf/make-frame yourself first, the mount joins
and your :initial-events never run. Pick one.
:initial-events drain before the first paint, in order, so the first render
is the seeded one rather than an empty frame filled in a moment later.
Chapter: Installation.
A list, a row, and an intent¶
The ordinary case. A parent reads a collection, a keyed child renders one member, and every handler is a vector.
(ns my.app.views
(:require [re-frame.hicasso :as h]
[my.app.events :as events]
[my.app.subs :as subs]))
(h/defview todo-row
"One to-do: a checkbox, its title, and a delete button."
[{:keys [id title done?]}]
[:li.todo-row {:class (when done? "completed")}
[:input.toggle
{:type "checkbox"
:aria-label (str "Done: " title)
:checked done?
:on-change [::events/toggle id ::h/checked]}]
[:label.title title]
[:button.destroy
{:type "button"
:aria-label (str "Delete " title)
:on-click [::events/delete id]}
"×"]])
(h/defview todo-list [_]
(let [rows (h/sub [::subs/visible-todos])]
[:ul.todo-list
(for [{:keys [id] :as todo} rows]
[todo-row (assoc todo :key id)])]))
todo-row is a Hiccup head, not a function to call. Write [todo-row {…}]
where a call site would write one. For markup that should inline into its caller
rather than become its own re-render unit, use a plain defn.
Key by identity, never by index. A keyed list whose key is its position reuses the wrong row the moment the order changes.
::h/checked and ::h/value substitute at dispatch time, at the intent
vector's top level. [::events/toggle id ::h/checked] arrives at the handler as
[::events/toggle 7 true], so nothing in this file is a closure and nothing
reads a DOM event.
Chapters: Views and reads, Lists and collections, Events as data.
A text field the model owns¶
A controlled field writes every edit straight to app-db. This is the first recommendation and stays it.
(h/defview search-field [_]
(let [term (h/sub [::subs/term])
revision (h/sub [::subs/revision])]
[:div.search
[:label {:for "search-term"} "Search"]
[:input#search-term
{:type "text"
:value term
::h/revision revision
:on-input [::events/typed ::h/value]
:on-key-down {"Enter" [::events/submit]
"Escape" [::events/clear]}}]]))
Write :on-key-down as a map, not as a callback reading .key. The map is
lowered once per render into a plain string-to-handler map, so an event costs one
lookup and no allocation — and it is composition-gated centrally, so a keystroke
arriving mid-IME-composition commits nothing. A hand-written .key test yields
an application that works and is wrong for every user who composes.
::h/revision is what makes clear work. Dropping the model's text moves
the value back to the empty string, and if the field was already showing an empty
string React would see nothing to do. A changed revision re-baselines the field
to the model without remounting it. Advance it whenever a reset must happen
regardless of whether the value moved.
A form gets Enter for free. A text input inside a <form> submits on Enter,
and :on-submit prevents by default, so this is the whole of Enter adds an
item:
(h/defview new-todo-box [_]
[:form.new-todo-form {:on-submit [::events/add]}
[:label {:for "new-todo"} "What needs to be done?"]
[:input#new-todo
{:type "text"
:value (h/sub [::subs/new-todo])
:on-input [::events/typed ::h/value]}]])
:on-submit is the only position that prevents by default. Elsewhere — an anchor
acting as a button, say — wrap the intent: [::h/prevent [:filter/show-done]].
Chapter: Controlled inputs.
A draft the user can abandon¶
Some fields cannot write straight through: a row edited in place, a value the server may normalise or refuse, anything the user must be able to walk away from. Those need a draft in front of the committed value.
(ns my.app.views
(:require [re-frame.core :as rf]
[re-frame.hicasso :as h]
[re-frame.hicasso.forms :as forms]
[my.app.events :as events]
[my.app.subs :as subs]))
(h/defview title-field [{:keys [id]}]
[forms/buffered-field
{:control [:todo id :title]
:value (h/sub [::subs/title id])
::h/revision (h/sub [::subs/title-revision id])
:on-commit [::events/title-committed id]
:on-cancel [::events/edit-cancelled id]
:placeholder "What needs doing?"}])
The protocol is fixed. Focus alone creates nothing; the first edit starts the
session. Enter and blur both append the draft to :on-commit and dispatch it, so
[::events/title-committed id] arrives as
[::events/title-committed 7 "Buy oat milk"]. Escape clears the draft and shows
:value again. Unmount neither commits nor cancels, so a virtualised row can
leave and come back without losing its draft.
Your :on-commit handler decides the outcome, and says so by moving the
revision. Accept by writing the candidate; normalise by writing something else;
reject by leaving the committed value alone — and in the last two cases advance
the revision as well, because retaining the old value changes nothing the field
reads, and a reset that only works when the value happens to move is not a reset.
End a durable draft explicitly. A draft survives re-render, remount, virtualisation and navigation — that is the point of putting it in app-db — so route entry, an explicit cancel and a successful save reply each need to say so:
(rf/reg-event ::events/saved
(fn [{:keys [db]} [_ id]]
{:fx [[:dispatch [::h/clear forms/drafts [:todo id :title]]]]}))
Chapter: Forms.
Fetch, show progress, and keep the last good answer¶
A panel that paints nil while a request for new data is out blanks itself on
every keystroke. Read both the live answer and the rows you already hold, and
prefer the live one.
(h/defview suggestions-panel [{:keys [term]}]
(let [rows (h/sub [::subs/suggestions term])
status (h/sub [::subs/status])
painted (or rows (h/sub [::subs/held-rows]))]
[:div.suggestions
(cond
(= :failed status) [:p.problem {:role "alert"} (str (h/sub [::subs/problem]))]
(nil? painted) [:p.loading "Searching…"]
(empty? painted) [:p.empty "No matches"]
:else [:ul
(for [row painted]
[suggestion-row (assoc row :key (:id row))])])
(when (contains? #{:loading :refreshing} status)
[:span.busy {:aria-live "polite"} "Busy"])]))
The read is passive, so something else has to cause the fetch.
[::subs/suggestions term] projects whatever the cache already holds for that
term. Rendering the panel starts no request and unrendering it releases nothing.
The event that decides a new term is wanted is the one that releases the
previous owner and ensures the new read.
Refresh-with-data is a policy, and it is yours. Keeping the held rows on screen while a new request is out is the right default for a typeahead and the wrong one for a balance. Write it where the decision belongs — in the body — not in the subscription.
Model the failure as a state, not as an exception. :failed here is app-db
saying so, which is what makes the role="alert" region ordinary markup.
Chapter: Async resources.
Links that change the URL¶
(ns my.app.views
(:require [re-frame.hicasso :as h]
[my.app.routes :as routes]
[my.app.subs :as subs]))
(h/defview feed-page [_]
(let [rows (h/sub [::subs/feed])]
[:section.feed
[:h2.pane-heading {:tab-index -1 :data-route-heading "true"} "Articles"]
[:ul.article-list
(for [{:keys [slug title]} rows]
[:li.article-row {:key slug}
(h/route-link {:to routes/article :params {:slug slug} :class "article-link"}
title)])]]))
(h/defview article-page [_]
(let [slug (:slug (h/sub [:rf.route/params]))
article (h/sub [::subs/article slug])]
[:section.article
[:h2.pane-heading {:tab-index -1 :data-route-heading "true"}
(if article (:title article) "No such article")]
(h/route-link {:to routes/feed :class "back"} "Back to the list")]))
h/route-link is called, not written as a head. It is a plain function —
(h/route-link {…} "text") — because a link is not a unit of re-render. Nothing
at the call site says which grammar applies, so this is the one spelling in the
door worth memorising.
The address bar is the source of truth. Read the parameters back through routing's own subscription rather than threading them down as props, and the URL and the page cannot disagree.
Give the landing target a tab-index -1. A heading is not natively
focusable, so without it a completed navigation leaves focus on <body> and a
keyboard user has no idea the page changed. -1 and not 0: this is a
programmatic focus target, not a new stop on the Tab order.
Chapter: Routing and navigation.
A foreign React component¶
Declare the crossing once, then use the resulting var as a Hiccup head anywhere.
(ns my.app.views
(:require [re-frame.hicasso :as h]
[my.app.events :as events]
[my.app.subs :as subs]
[my.app.vendor :as vendor]))
(h/defhost rows
"The declared door onto the virtualiser."
vendor/virtual-rows
{:callbacks {:render-row :render
:on-window :event}})
(h/defview ledger [_]
(let [total (h/sub [::subs/row-count])]
[:div.ledger {:role "grid" :aria-rowcount (str total)}
[rows {:count total
:row-height 28
:render-row (h/event [i offset]
(h/as-element
[ledger-row {:key (str "row-" i) :index i :offset offset}]))
:on-window (h/event [from to]
[::events/window-shown {:from from :to to}])}]]))
Declare each callback's contract; never infer it from an on* spelling.
:render means the vendor calls it during its own render and uses what comes
back; :event means a returned vector is dispatched under the frame of the
boundary that wrote the crossing.
Both callbacks here are h/event rather than intent vectors, because this
vendor invokes them value-first — renderRow(index, offset) — so there is no DOM
event at argument one for a vector's markers to read. At a genuinely event-first
foreign callback the vector spelling is legal and shorter.
A :render return crosses unconverted, which is why h/as-element is there:
the wrapper ends in a bare call, so a returned Hiccup vector would reach React,
which refuses it. The row keeps its intents — they fire later, into the frame of
the boundary that supplied the callback.
Markup at a prop needs :slots. At an undeclared prop a vector stays data,
silently, because whether it is markup is a fact about the foreign ABI and only
you hold it:
(h/defhost modal vendor/Modal
{:callbacks {:on-close :event}
:slots #{:title :footer}})
[modal {:on-close [::events/cancel]
:title [:h2 "Delete article?"]
:footer [:button {:on-click [::events/delete id]} "Delete"]}]
Say {:server :render} when the component is safe on the server. The default
is :client-only: the region renders nothing on the server and nothing on
hydration's first pass, and a declared :fallback renders inert markup there
instead. :server :render is an assertion, it mints no gate at all, and it is
the only policy under which a crossing's children reach the server response —
which is what a transparent wrapper such as a context provider needs.
Chapter: Interop.
A region that can fail, and be retried¶
Put the boundary around the region, not around the application. An application with one boundary at the root has exactly one failure mode, and it is the screen went away.
(h/defview digest [_]
(let [blocks (h/sub [::subs/digest-blocks])
loading? (h/sub [::subs/digest-loading?])]
[:section.digest
[:h3 "Digest"]
[h/error-boundary
{:reset-key blocks
:fallback [:div.digest-error {:role "alert"}
[:p "That section could not be shown."]
[:button {:type "button"
:disabled loading?
:on-click [::events/reload-digest]}
(if loading? "Loading…" "Try again")]]
:on-error [::events/record-failure]}
[digest-body {}]]]))
Make the reset key the content, not a counter. :reset-key is compared with
=, so reading the blocks themselves clears the caught failure exactly when
different content arrives — and, the half a counter gets wrong, it does not
clear when the same broken payload arrives again. A counter would reset the
boundary, re-render the same bad block, throw again, and show the fallback a
second time after a visible flicker.
The retry is yours to schedule. The boundary never guesses one: the button above dispatches an ordinary event, and the new content is what resets the region.
:on-error fires once per caught failure. A vector is dispatched with the
error appended, through the frame the boundary is mounted under; a plain function
is called with the error. It carries no other shape — a bare keyword is refused
at the first paint rather than silently reporting nothing.
Write the fallback as ordinary markup. It is rendered by a body that ran fine, so subscription reads inside it work exactly as they do anywhere else — which is how a localised application avoids a hardcoded English sentence at the worst possible moment.
Chapter: Errors.
Render on the server, and adopt it on the client¶
Two halves, and they are not symmetric. The server produces a document; the client installs the state and then adopts the DOM.
(ns app.server
(:require [re-frame.hicasso.server :as server]
[my.app.views :as views]))
(defn handle [request]
(let [{:keys [document]}
(server/render {:hiccup [views/page {}]
:snapshot (initial-db request)
;; the framework's fail-closed policy: an allowlist of
;; top-level app-db keys, or :rf.ssr.payload/whole-app-db
:payload [:articles :session]
:client-frame-id :app/main
:identifier-prefix "main"
:script-src "/js/main.js"
:title "My application"})]
{:status 200 :headers {"content-type" "text/html"} :body document}))
Check determinism where the renderer is, which is here, in the server
namespace. A view reading Date.now or generating a random id produces a
document that differs run to run, which hydration then reports as a mismatch on
someone else's machine. Hand render-twice the same options map you hand
render:
(let [{:keys [identical? differs-at]} (server/render-twice opts)]
(assert identical? (str "server render is not deterministic at " differs-at)))
The client half is a different file, and it installs state before it touches the DOM:
(ns my.app
(:require [re-frame.ssr :as ssr]
[re-frame.hicasso :as h]
[my.app.views :as views]))
(defn ^:export -main []
(ssr/hydrate! {:frame :app/main}) ;; 1. state
(h/hydrate! (js/document.getElementById "app") ;; 2. DOM
{:frame :app/main :identifier-prefix "main"}
[views/page {}])
nil)
State comes first, and it is a different door. h/hydrate! adopts DOM and
nothing else. Unlike h/mount! it does not ensure or seed its frame, and it
has no :initial-events key — an adopting root takes its state from the server
payload, and a seed here would overwrite exactly what the server rendered from.
Hand both sides the same :identifier-prefix. React numbers useId per root
and prefixes it with this option, so a hydrating root given a different prefix —
or none, where the server had one — resolves every id in the tree differently
from the bytes it is adopting.
Adoption finishes after the call returns. h/hydrate! performs no
flushSync, so the DOM on the next line is still the server's. Anything that
must run after adoption waits for the adoption window to close rather than for a
flush.
Chapter: SSR and hydration.