Installation¶
Chapter 00 of the Fresco draft guide. This page adds Fresco to a browser
build and mounts a small counter. The example establishes the three forms used
throughout the guide: h/defview for a view,
h/sub for a subscription read, and event vectors for
ordinary handlers.
There is no Fresco variant of the re-frame2 app template — it scaffolds
:reagent and :uix, and neither emits Fresco views. Build by hand from this
chapter instead: it names every file a Fresco project needs, and the result
boots.
Add the dependencies¶
Fresco ships in the re-frame2 release set
day8/re-frame2-fresco is one of the coordinates every re-frame2 release
publishes, at the same version as day8/re-frame2 itself, so pin them as
a set. Until a release, every re-frame2 artefact resolves from source, and
this page resolves Fresco — and day8/re-frame2 with it — from a checkout
using :local/root. Once you are on a release, the one :local/root entry
below becomes an ordinary {:mvn/version …} coordinate and nothing else
on this page changes.
:local/root is relative to your deps.edn, so clone the monorepo beside
your project directory — the convention the rest of the docs use:
Then add the Fresco artifact to deps.edn. One coordinate is the whole of it:
Fresco ships its own substrate adapter,
so there is no second dependency to add for the reactive plumbing.
;; deps.edn — resolved from a re-frame2 checkout beside your project
{:paths ["src"]
:deps {day8/re-frame2-fresco {:local/root "../re-frame2/implementation/fresco"}}
;; shadow-cljs reads its classpath from this file, so the compiler is a
;; dependency here as well as an npm package below.
:aliases
{:shadow {:extra-deps {thheller/shadow-cljs {:mvn/version "3.4.10"}}}}}
The Fresco artifact brings day8/re-frame2 with it. React and the shadow-cljs
launcher come from npm:
{
"dependencies": {"react": "19.3.0", "react-dom": "19.3.0"},
"devDependencies": {"shadow-cljs": "3.4.10"}
}
Both npm lines earn their place. Pin React, and pin it at 19.2 or newer.
Fresco mounts through createRoot and lets :identifier-prefix decide what
useId answers, both of which React has offered since 18 — but the lifecycle
contract Fresco holds itself to is written against
<Activity>, which shipped in
19.2, and 19.2 is the pair the reference implementation runs and this chapter is
checked against. React 18 is not supported: nothing tests Fresco there, and
the Activity half of the contract has nothing to run on. A bare
npm install react react-dom resolves to whatever is current that day, which is
the other half of why the pin is written out. And keep shadow-cljs in
devDependencies even though the JVM dependency above is what compiles: the
npm package is where the process shim React's CommonJS build asks for comes
from, and without it the build stops at
The required JS dependency "process" is not available.
Fresco interprets Hiccup at runtime, so it needs no compiler hook, macro allow-list, or build flag. A normal shadow-cljs browser build is enough:
;; shadow-cljs.edn
{:deps {:aliases [:shadow]}
:dev-http {8080 "public"}
:builds {:app {:target :browser
:output-dir "public/js"
:asset-path "/js"
:modules {:main {:init-fn counter.core/init}}}}}
{:deps {:aliases [:shadow]}} is what puts the compiler on the classpath. A
bare {:deps true} reads deps.edn without the alias, finds no
thheller/shadow-cljs there, and dies before it compiles anything:
Could not locate shadow/cljs/devtools/cli.
<!-- public/index.html -->
<!doctype html>
<html>
<body>
<div id="app"></div>
<script src="/js/main.js"></script>
</body>
</html>
The main namespace is re-frame.fresco, conventionally required as h.
Forms, overlays, motion, routing, the island hooks, and the test kit use separate
namespaces. A build that does not require an optional module does not include
its code.
Supported versions¶
The pins above are not arbitrary, and this table says what stands behind each of them. Tested means a gate in the re-frame2 repository runs Fresco against that combination. Expected means the code has no version-conditional branch that would make it fail there, but nothing measures it — a reasonable bet rather than a promise. Nothing here is forbidden; the distinction is only between what is checked and what is not.
| Combination | Tested | Expected, but unmeasured |
|---|---|---|
| React and react-dom | 19.3.0, on every browser and Node lane Fresco runs | Later 19.x, for the render boundary and the two-hook contract. Below 19.2 the <Activity> lifecycle rows have nothing to run on, and 18 and earlier is not supported at all |
| Browser engine | Chromium, on the headless DOM lane. Firefox and WebKit, whenever a change touches the Fresco surface | Any other engine or version — the substrate targets React's DOM contract rather than any one browser's |
| ClojureScript and shadow-cljs | 1.12.145 and 3.4.10 | Nothing else is measured |
re-frame2 core and re-frame2-ssr |
the same checkout as Fresco, which :local/root is what guarantees |
No mixed-version pairing: every release ships all three at one version, so pin them as a set |
The full matrix — every row above, plus the platform axis and the named CI job
or explicit untested-but-expected label behind each one — is maintained in the
repository as the Fresco release policy, under
docs/design/fresco/product/release-policy.md. That page is a working design
record rather than part of this site, so it is read from a checkout.
Upgrades before 1.0 may cost you a rename, and should not cost you a rethink. Every published artifact ships at the same version through 1.0, and the project ships no back-compatibility shims: a renamed or removed door is a compile error at your own call site rather than a deprecation warning, so the compiler enumerates every site that has to move. What is genuinely promised in the meantime is the complaint ids — an id never changes meaning or spelling, and a retired one is never reused — because stored errors and monitoring rules outlive the code that raised them. The complaint index is the list those promises are about.
Fresco needs a substrate adapter¶
Fresco is a view layer, not a substrate. It owns
Hiccup interpretation and the render boundary; the reactive container app-db
lives in comes from an adapter, and re-frame2 installs
none for you. So every Fresco application calls
rf/init! with an adapter before it mounts anything —
one line, on the first line of boot:
re-frame.fresco.substrate ships inside day8/re-frame2-fresco, so that
line costs no coordinate. It is a separate namespace rather than a name on the
h door for the reason every optional Fresco module is: an application that
installs somebody else's adapter never requires it and never carries it.
It is not optional and it does not fail quietly. h/frame-root ensures its
frame, creating a frame asks the adapter for a state container, and a container
asked for before init! throws:
rf/make-state-container was called before (rf/init! ...); require an adapter
ns and pass its `adapter` Var, e.g. (rf/init! reagent/adapter).
[:rf.error/no-adapter-installed]
Which adapter is your choice, and it is the only line that changes between substrates — see Use UIx or reagent-slim for the shipped alternatives and their coordinates. A Reagent or UIx adapter under a Fresco tree keeps working exactly as it did, and is what you want when the page also renders that substrate's own components: every React-shaped adapter writes the same frame context, so a Fresco subtree and that substrate's own subtree resolve one frame. What it costs is a second dependency whose notation you never write, which is why Fresco's own is the default here.
One frame, but not one markup dialect. That shared context is what a
component crossing reads; it is not permission to interleave the two notations.
A Reagent view is not a legal Hiccup head, so [reagent-footer] written inside a
Fresco body raises :rf.error/fresco-bad-head at the first paint — and because
a Reagent view is an anonymous meta-carrying function, the refusal can name the
enclosing view but not the offender. The two directions are not symmetric:
- Fresco inside a foreign parent has a named door.
h/as-componentmints a real React component from a Fresco head, andh/as-elementconverts one subtree; a Reagent, UIx, React or plain-JavaScript parent then mounts either under the frame it is already in. See Render a Fresco view from native React. - Reagent inside a Fresco tree has no Fresco door.
[:>]andh/defhosttake real React components, which a Reagent view is not. Lifting it withreagent.core/reactify-componentand crossing at the raw escape does work, but that is Reagent's own bridge plus a general escape rather than something this package offers, and it puts two renderers in one tree.
So the practical boundary between the two layers is a root, not a tag. Mix or
migrate a screen at a time, and where one page must genuinely show both, give
each layer its own root naming the same :frame — see More than one
root.
An adapter buys plumbing, not notation: you write Fresco views either way and never call the adapter yourself. The headless plain-atom adapter is not a substitute for a browser app — its derived value registers no watch, so a subscription under it notifies nothing.
Mount a first screen¶
A production application normally separates registrations and views into
several namespaces. This complete example keeps them together so the boot
sequence is visible — adapter first, then the root, inside the one init the
build calls:
(ns counter.core
(:require [re-frame.core :as rf]
[re-frame.fresco.substrate :as substrate]
[re-frame.fresco :as h]))
(rf/reg-event :counter/initialise
(fn [_cofx _event]
{:db {:count 0}}))
(rf/reg-event :counter/increment
(fn [{:keys [db]} _event]
{:db (update db :count inc)}))
(rf/reg-sub :counter/count
(fn [db _query]
(:count db)))
(h/defview counter [_]
[:main
[:h1 "Clicked " (h/sub [:counter/count]) " times"]
[:button {:on-click [:counter/increment]} "Click me"]])
(defonce app-root (h/client-root))
(defn ^:dev/after-load mount! []
;; The WHOLE tree, boundary head included — the frame is spelled in the
;; tree, so a render that drops the head renders a root with no frame
;; under it. Re-rendering the head is free: it ENSUREs, finds the frame
;; live and reuses it.
(h/render! app-root
[h/frame-root {:id :rf/default
:initial-events [[:counter/initialise]]}
[counter]]
(js/document.getElementById "app")))
(defn ^:export init []
(rf/init! substrate/adapter)
(mount!)
nil)
init is the build's :init-fn, wired in shadow-cljs.edn above. Namespace
load registers handlers and defines views and touches no DOM, so a test host, a
Story tool, or another namespace can require this one for its registrations
alone — the rule Boot and mount an
app states
for every substrate.
Start the build and open the page:
The example uses three Fresco rules:
h/defviewcreates a Hiccup head. Render it as[counter]or[counter {}]; do not call it as(counter {}). Use a plaindefnfor markup that should inline into its caller.h/subreads during the synchronous view body. It is legal inside alet, conditional, loop, or ordinary helper called by that body.{:on-click [:counter/increment]}is an intent. The runtime creates the callback and dispatches the event vector to this root's frame.
What the boot creates¶
Two things, and they are two on purpose. h/render! makes
the React root: the FIRST call through a handle receives a DOM node, one view
form and root options only, and creates the root. [h/frame-root {:id ...}]
makes the frame -- its own app-db, event queue and subscription cache --
and scopes it to everything beneath it in the tree.
That one verb is also the hot-reload hook, which is why mount! above carries
^:dev/after-load and init just calls it. Every later h/render! through
app-root UPDATES the root it already owns, so the DOM, the subscriptions and
every scrap of component state survive a reload. There is no second verb that
could build a second root by mistake, and no root state for the page to keep.
h/frame-root ensures the frame it names: it creates it if it does not
exist, or reuses the live one as it stands if another boundary already holds it.
:initial-events run once, in order, on the ensure that CREATES the frame. They
complete before the first paint, which prevents an empty initial render -- the
ensure runs in a layout effect and h/render! renders inside flushSync, so the
door returns with the seeded markup already on the page. Initial state still
arrives through events; Fresco does not add a separate :db seed option.
This is the same frame-root / frame-provider pair every re-frame2 view
substrate spells, so a boot written here reads like a Reagent or UIx one. The
Frames spec is the contract all of them realise.
[counter] and [counter {}] are equivalent. The body receives an empty props
map in either case.
Keep the returned handle for later renders and teardown. h/render! takes the
whole tree, boundary head and all — the frame lives in the tree now, so a
re-render that drops the head renders a root with no frame under it. Hand the
head the same options the mount did: a committed frame-root scopes one frame
for its lifetime, so a re-render that changes its option map — dropping
:initial-events because they have already run, most temptingly — is
:rf.error/frame-root-reconfigured rather than a silent no-op. That is why
rerender! above renders the same form the boot did.
(h/render! root [h/frame-root {:id :rf/default
:initial-events [[:counter/initialise]]}
[counter]])
(h/unmount! root)
h/unmount! is idempotent. A second call is a no-op because teardown can be
reached independently by fixtures, reload hooks, and finally blocks. The
unmount releases the root's subscriptions as their reference counts reach
zero and makes the DOM node available for another root.
A bare mount catches nothing. Every Fresco refusal is a throw, and React
unmounts a root whose tree throws with no error boundary above it — the whole
page, not the offending region, with the error only in the console. Put
h/error-boundary around the regions a user can carry on without, which for
most applications means a route's main content rather than the root itself;
Errors is the whole
rule and Routing and navigation
shows it in a routed root.
A frame that needs more than a seed¶
h/frame-root takes the whole rf/make-frame option map, so there is no
curated subset to be caught out by. :url-bound? true for an application that
owns the browser URL, :fx-overrides for a stubbed backend, :images,
:platform — every one of them rides the head:
(defn ^:export init []
(rf/init! substrate/adapter)
(h/render! app-root
[h/frame-root {:id :app/main
:url-bound? true
:initial-events [[:app/initialise]]}
[main-screen]]
(js/document.getElementById "app"))
nil)
Naming the frame ONCE, where it is used, is the whole point of the shape.
Fresco used to make a boot like this call rf/make-frame first and mount to
JOIN, because the root door's config could not carry :fx-overrides; the
boundary in the tree retired that detour, and the door now REFUSES a frame
option rather than dropping it.
Routing and navigation
walks the routed case, which is the common one — a frame owns the browser URL
only by carrying :url-bound? true, and nothing supplies it by default.
More than one root¶
A page can mount several Fresco roots. The frame id determines whether those roots share an application.
Two roots sharing one frame¶
The first root's h/frame-root creates and seeds the frame. A later root
scoping the same frame reads its current state, and a second h/frame-root
under the same :id reuses it without replaying :initial-events:
;; TWO roots, so TWO handles: one handle owns at most one root at a time.
(defonce app-root (h/client-root))
(defonce status-root (h/client-root))
(defn ^:export init []
(rf/init! substrate/adapter)
(h/render! app-root
[h/frame-root {:id :app/main
:initial-events [[:app/initialise]]}
[main-screen]]
(js/document.getElementById "app"))
(h/render! status-root
;; The frame already exists, so the second root SCOPEs it
;; rather than ensuring it again. A `frame-root` here would
;; work too — ensure is idempotent — but `frame-provider`
;; says out loud which root owns the seed, and fails loud
;; if this one is ever booted alone.
[h/frame-provider {:frame :app/main}
[connection-badge]]
(js/document.getElementById "status"))
nil)
Both roots read the same app-db and dispatch into the same queue. Seed from the boundary that creates the frame; a scoping root carries no seed at all.
Unmounting one root does not destroy state still used by another root. Teardown remains per root.
Two roots using different frames¶
Give each root a different frame id when they must be isolated. Each frame then has its own app-db, queue, and subscription cache. A view reads under the frame of the root that renders it, and subscriptions never cross frames.
This is suitable for cases such as an editor and a live preview that use the same view code but must not share state.
Hot reload¶
The ^:dev/after-load hook calls h/render! with the redefined view. The root,
frame, app-db, and subscriptions survive, so changing the view does not reset
the counter or leak registrations.
The DOM does not survive with them. A reload re-evaluates the namespace, so
every h/defview in it is a new component type, and h/as-component's def
mints a new one too. A changed type is a remount rather than an update by
React's own rule, so the re-render rebuilds those nodes instead of updating
them. State held in app-db comes back untouched; state the DOM itself owns does
not, and focus and the caret are the two you notice — edit a form's markup while
typing in that form and the draft survives in app-db while the cursor leaves the
field. Nothing is wrong when that happens, and it is not a reason to reach for
defonce on a view.
Boot and re-render are two functions rather than one because shadow calls
:init-fn once, when the module loads, and not again after a reload — a
build whose only entry point is :init-fn logs reloading code but no
:after-load hooks are configured! and leaves the page showing the old view.
Mount in init; re-render in the hook.
Hot reload also means a changed initialisation handler does not re-seed an already live frame. Reload the page, destroy the frame, or dispatch an explicit reset event when you need the new initial state.
Production builds¶
Build the release normally:
Advanced compilation removes development-only warnings, warning strings, and Xray instrumentation hooks. Optional modules that were never required add no code. The Hiccup interpretation itself has the same meaning in development and production; there is no production-only view mode.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
A mount throws :rf.error/no-adapter-installed, naming rf/make-state-container |
No adapter is installed: rf/init! never ran, or ran after the mount |
Make (rf/init! substrate/adapter) the first line of boot |
(counter {}) throws and names the view |
A defview is a Hiccup head, not a directly callable helper |
Render [counter {}]. Use a plain defn for inline markup |
h/sub in a callback, timer, or promise throws |
:rf.error/fresco-sub-outside-render: the read happened outside a synchronous view body |
Read during the body and close over the value. Async work should read state through events and coeffects |
| The first paint is empty and then fills in | Initial state was dispatched after mounting | Put the seed events in h/frame-root's :initial-events so they finish before the first paint |
| A hot reload replaced the whole tree instead of updating it | A FRESH handle was allocated on reload, so its first h/render! built a second root |
Allocate the handle with defonce, so a reload re-evaluates the namespace without replacing the handle |
A reusing h/frame-root's :initial-events never run |
The named frame already exists; ensure reuses without re-seeding | Seed only from the boundary that creates the frame |
h/render! throws :rf.error/fresco-frame-config-misplaced |
:frame or :initial-events was handed to the root door; frame configuration lives on the boundary in the tree |
Move it to [h/frame-root {:id … :initial-events …}] (above) |
h/render! throws :rf.error/fresco-unknown-root-option |
A root door carries :hydrate? and :identifier-prefix and nothing else, and refuses the rest rather than ignoring it |
Put every rf/make-frame option on h/frame-root |
| One refused head blanks the whole page | A Fresco refusal is a throw, and React unmounts a root that throws with no boundary above it | Wrap independently recoverable regions with h/error-boundary (Errors) |
| A changed initialisation handler has no effect after hot reload | The live frame kept its existing app-db | Reload, recreate the frame, or dispatch an explicit reset event |
| A view body runs twice when first mounted in development | React StrictMode probes bodies twice | Expected. Keep view bodies pure and safe to re-run |