Skip to content

Boot and mount an app

Your app's entry namespace has three jobs:

  • require the namespaces that register the app's behaviour;
  • install the reactive adapter for the process;
  • render a view tree inside a frame.

On first page load that creates and seeds the app. On hot reload it re-renders the changed views without losing app-db.

A frame is one isolated running instance of your app, with its own app-db, event queue, and subscription cache. rf/init! does not create one. It only installs the adapter for your React substrate, such as Reagent or UIx. The frame is created when the frame-root you render first mounts.

Fresco apps boot the same way

Fresco still takes its reactive container from an adapter, so a Fresco app opens with the same (rf/init! …) line. Its tree carries [h/frame-root {:id …}] where a Reagent tree carries [rf/frame-root {:id …}], with the same options and behaviour, and it mounts through the same client-root / render! / unmount! trio.

The small shape

For an app with no browser listeners, you don't need a separate boot! function. Keep the process setup inline in run, and put the DOM work in mount!.

(ns counter.core
  (:require [re-frame.core :as rf]
            [re-frame.adapter.reagent :as reagent-adapter]
            ;; These namespaces are required for their registrations.
            [counter.events]
            [counter.subs]
            [counter.views :refer [counter]]))

;; Namespace load does no DOM work. The handle is inert until the first
;; render! through it creates the React root.
(defonce app-root (reagent-adapter/client-root))

(def app-frame :app)

(defn ^:dev/after-load mount! []
  (when-let [el (and (exists? js/document)
                     (js/document.getElementById "app"))]
    (reagent-adapter/render! app-root
      [rf/frame-root {:id             app-frame
                      :initial-events [[:initialise 0]]}
       [counter]]
      el)))

(defn run []
  (rf/init! reagent-adapter/adapter)
  (mount!))

Wire run as the build's :init-fn — for example :init-fn counter.core/run in shadow-cljs.edn.

rf/init! is process setup and mount! is browser setup. Keeping the DOM work inside mount! lets the namespace load in tests or Node hosts where js/document is not present.

Keep render! and listener installation out of top-level code too: a test host, a Story tool, or another namespace may load this one for its registrations without mounting the app.

;; Don't do this at namespace load.
(reagent-adapter/render! app-root [counter] (js/document.getElementById "app"))

The ns form is also part of boot. counter.events and counter.subs look unused, but requiring them runs their reg-event and reg-sub forms. Loading the code is the registration; there is no manifest and no wiring step. A real app's entry namespace requires every namespace whose registrations must exist before the app runs: events, effects, coeffects, subscriptions, views, routes, resources, machines, and schemas.

What the root does

This form:

[rf/frame-root {:id             app-frame
                :initial-events [[:initialise 0]]}
 [counter]]

is frame-root, the component that ensures a frame exists. On the first mount it:

  • creates the frame named by :id;
  • applies the frame config;
  • runs the :initial-events once, in order, to seed app-db (they are ordinary events with ordinary handlers);
  • scopes descendant views, subscriptions, and dispatches to that frame.

On a later remount under the same :id, such as a hot reload, it reuses the live frame. It does not replay :initial-events, and it does not destroy the frame on unmount. That is why app-db survives a reload.

It also means an edit to the setup event does not run on reload. To run the new setup, reload the page, or reset the frame (rf/destroy-frame! followed by rf/make-frame with the same config).

Hot reload

Two pieces make hot reload work:

(defonce app-root (reagent-adapter/client-root))

(defn ^:dev/after-load mount! []
  ...)

defonce keeps the same handle across reloads, and the handle keeps the same React root: the first render! through it creates the root, and every later one updates that root. You never hold a raw React root or write a create-or-render branch.

^:dev/after-load tells shadow-cljs to call mount! after a successful reload. That re-renders the edited views into the same root and the same frame. It does not re-run run.

The adapter tracks that root like every other root it creates, so rf/destroy-adapter! releases it too. reagent-adapter/unmount! releases it explicitly; both are safe to repeat.

Host listeners

Some apps also install browser listeners, such as storage, online, or visibilitychange. They are browser wiring, separate from frame creation.

The browser removes a listener by function identity, and after a hot reload your namespace holds new function objects. Removing "the listener" by name removes nothing, and each reload stacks another copy. Keep the installed listener in a defonce cell and remove that stored value before adding the new one. When listener code can change during development, reinstall it from the hot-reload hook as well as from run.

(defonce storage-listener (atom nil))

(defn- on-storage [event]
  ;; A browser callback has no frame in scope, so name the frame explicitly.
  (rf/dispatch [:todo/storage-changed (.-key event)]
               {:frame app-frame}))

(defn- install-host-listeners! []
  (when-let [previous @storage-listener]
    (.removeEventListener js/window "storage" previous))
  (.addEventListener js/window "storage" on-storage)
  (reset! storage-listener on-storage))

(defn mount! []
  ...)

(defn ^:dev/after-load reload! []
  (install-host-listeners!)
  (mount!))

(defn run []
  (rf/init! reagent-adapter/adapter)
  (install-host-listeners!)
  (mount!))

install-host-listeners! earns its own name because it runs in two situations: first page load and hot reload. A separate boot! wrapper is optional; use one only if it makes your app's entry point clearer.

In this shape, put ^:dev/after-load on reload!, not on mount!, so a reload reinstalls listeners and renders once.

URL routing needs none of this: a frame declared :url-bound? true installs and manages its own hashchange / popstate listener (see the TodoMVC example below).

Two frame components

Roots ensure a frame exists; providers scope a frame that already exists. The full split and its edge cases are in Frames.

Need Use
Subtree brings its own frame into being frame-root {:id … :initial-events …}
Frame already exists (boot, test, SSR, tooling) make-frame then frame-provider {:frame …}
;; App root — ensure + scope in one form
[rf/frame-root {:id app-frame :initial-events [[:initialise 0]]}
 [counter]]

;; Pre-created frame — scope only
(rf/make-frame {:id :todos/work :initial-events [[:todo/initialise]]})
[rf/frame-provider {:frame :todos/work}
 [todo-app]]

Troubleshooting

Each of these wiring mistakes fails loud with a named error rather than a blank page.

Symptom Cause Fix
:rf.error/no-adapter-installed when the frame-root creates its frame mount! ran before (rf/init! …) Call rf/init! first, as run does above
:rf.error/no-frame-context from a dispatch or subscribe Called from a top-level form, or from a callback, with no frame in scope Dispatch from views or handlers inside the frame-root, or pass {:frame …}
:rf.error/frame-provider-frame-absent A frame-provider {:frame …} names a frame that was never created Create it with make-frame first, or use frame-root {:id …} (the two components)
:rf.error/no-such-handler / :rf.error/no-such-sub naming an id you did register The namespace holding that reg-event / reg-sub is never required, so it never loaded Add it to the entry namespace's :require list
:rf.error/initial-events-step-failed and nothing renders A setup event's handler threw, or a coeffect it requires is missing; the frame is torn down The error names the step and its event; fix that handler
:rf.error/frame-root-reconfigured after a hot reload You edited the frame-root options, such as its :initial-events; hot reload re-renders the mounted root, which keeps the options it was created with Reload the page

Worked examples

From re-frame v1

The old mount-root pattern rendered again after a hot reload while a global app-db survived as a top-level value. In re-frame2 the state container is an explicit frame: frame-root ensures it, and :initial-events seed it through the normal event pipeline. The full frame lifecycle is in Frames.