03 — Your first app¶
The smallest interesting program is a counter: a number, two buttons, the number changes. Let's build it.
The full source is in examples/reagent/counter/core.cljs. This chapter walks through it section by section, explaining what each piece is doing and why it's shaped the way it is. By the end you'll have seen every load-bearing primitive in re-frame2 at least once.
This chapter uses Reagent — the canonical CLJS view substrate, the one the rest of the guide uses. (re-frame2 also has UIx and Helix adapters; for adapter comparisons and the init! call shape across substrates, see chapter 19 — Adapters.)
What we're building¶
A page with a + button, a - button, and a number between them. Click +: the number goes up. Click -: it goes down. Default value: 5.
Yes, this is trivial. But the shape we use to build it is the same shape we'd use for a real app — same primitives, same wiring, same testing approach. If the small case doesn't feel right, the large case won't either.
The whole thing¶
Here's the file, in full, with the surrounding ceremony removed:
(ns counter.core
(:require [reagent.dom.client :as rdc]
[re-frame.core :as rf]
[re-frame.adapter.reagent :as reagent-adapter]))
;; Events
(rf/reg-event-db :counter/initialise
(fn [_db _event] {:count 5}))
(rf/reg-event-db :counter/inc
(fn [db _event] (update db :count inc)))
(rf/reg-event-db :counter/dec
(fn [db _event] (update db :count dec)))
;; Subscription
(rf/reg-sub :count
(fn [db _query] (:count db)))
;; View
(rf/reg-view counter []
[:div
[:button {:on-click #(dispatch [:counter/dec])} "-"]
[:span @(subscribe [:count])]
[:button {:on-click #(dispatch [:counter/inc])} "+"]])
;; Mount
(defonce root
(rdc/create-root (js/document.getElementById "app")))
(defn ^:export run []
(rf/init! reagent-adapter/adapter) ;; wire the Reagent substrate
(rf/dispatch-sync [:counter/initialise]) ;; seed app-db before first render
(rdc/render root [counter]))
That's everything. Copy-paste-runnable. Let's take it apart.
Chapter vs the runnable file. The listing above is the teaching shape: one view,
reg-event-dbfor the initialiser, the smallest thing that runs. The runnable inexamples/reagent/counter/core.cljsis intentionally a little richer — it splits the UI into two views (counter-buttons+counter-app) and widens:counter/initialisetoreg-event-fxwith a:fxwalk so the perf-instrumented build can exercise therf:fx:*perf bucket on init. Both shapes are equivalent for the counter's behaviour.
Initialisation¶
(defn ^:export run []
(rf/init! reagent-adapter/adapter)
(rf/dispatch-sync [:counter/initialise])
(rdc/render root [counter]))
Two things have to happen before the view can render: the runtime needs an adapter installed (so subscriptions know how to track reactivity), and app-db needs an initial value (so the first read of @(subscribe [:count]) returns something sensible). The run function does both.
(rf/dispatch-sync [:counter/initialise]) runs the :counter/initialise event synchronously, in-line, before run returns. By the time rdc/render mounts the view on the next line, app-db is {:count 5} and the first render shows 5.
Why dispatch-sync rather than plain dispatch? Plain dispatch puts the event on the queue and returns immediately — the handler runs on the next animation frame. If the view tried to render against an empty app-db on the way there, it would either show nothing or pop briefly before the seeded value arrived. dispatch-sync is the right hammer for seed-before-render: drain this one event right now, treat the result as part of mount.
You'll only reach for dispatch-sync in two places, both at app boundaries: at mount, like this, and inside tests where you want to assert on the post-handler state without yielding to the queue. Everywhere else, dispatch is what you want — fire-and-forget, the runtime handles ordering.
Events¶
Three things to notice:
-
The id is namespaced.
:counter/initialise, not:initialise. The convention is that every event's id starts with the feature it belongs to. This matters more as the app grows, but the habit starts here. An AI scaffolding new code reads existing event ids and picks a non-colliding one — namespacing makes that easy. -
The handler is a pure function of
(db, event) → db. The arguments start with_because we ignore them: this event doesn't read the previous state, and the event vector has no payload. The body returns the new state — a map with:count 5. -
There's no side-effect. The handler doesn't write to anything, doesn't fire HTTP requests, doesn't call
console.log. It computes a new state and hands it back. The runtime applies it.
The other two events are similarly pure:
(rf/reg-event-db :counter/inc
(fn [db _event] (update db :count inc)))
(rf/reg-event-db :counter/dec
(fn [db _event] (update db :count dec)))
update is Clojure's idiom for "transform a value at this key with this function." It returns a new map, leaving the old one untouched. Immutability everywhere.
Why pure handlers?¶
Because pure handlers are the easiest possible thing to test — pass in a state, pass in an event, check the output. No mocks. No setup. No teardown.
(deftest counter-inc-test
(let [handler (:handler-fn (rf/handler-meta :event :counter/inc))
before {:count 5}
after (handler before [:counter/inc])]
(is (= 6 (:count after)))))
But there's a deeper reason. A pure function has no time and no place. It doesn't matter when it ran or what the global environment looked like; given the same arguments, it returns the same value. That property is what lets you reason about the function in isolation. As soon as a handler can call out to the network, mutate global state, or check the wall clock, you've lost that property — and you've gained a class of bugs that are dynamic, time-dependent, and very hard to fix.
re-frame2 keeps handlers pure. When side-effects need to happen, the handler describes them as data and returns them. The runtime interprets. We'll see this when we move on to a more complex example.
Subscriptions¶
A subscription is a derivation: a function from app-db to some value. Here, :count is just (:count db) — read the :count key.
But the framing matters. By naming this derivation :count and making it a registered, queryable thing, we get:
- A view can subscribe to it without knowing the path in
app-db. If we move:countinto[:counter :count]later, only the subscription changes. - Tooling can list every derivation in the app: "show me everything anyone could read." Useful for AI code generation and human inspection.
- Tests can compute the subscription against any state value:
(rf/compute-sub [:count] some-db)— no React, no rendering, just data → data.
Subscriptions can also chain. A :count-doubled sub that depends on :count would be:
The :<- is the input declaration. The framework builds a dependency graph from these declarations; when :count changes, :count-doubled recomputes — but only when something is reading it. Cheap when nobody's looking, fast when they are.
The view¶
(rf/reg-view counter []
[:div
[:button {:on-click #(dispatch [:counter/dec])} "-"]
[:span @(subscribe [:count])]
[:button {:on-click #(dispatch [:counter/inc])} "+"]])
This is the only piece with substantive shape. Let's look at it carefully.
reg-view is a defn-shape macro. It registers a render function (under a namespaced keyword auto-derived from the registration site — for this file, :counter.core/counter) and defs the symbol counter in the current namespace, bound to the wrapped fn. Hiccup elsewhere can reference it as [counter]. The render function returns hiccup — a Clojure data structure that describes a DOM tree. [:div ...] is a <div>. [:button {:on-click ...} "-"] is a <button> with a click handler and the text "-".
Inside the body, two names are available that you didn't define:
subscribe—@(subscribe [:count])reads the current value of the:countsubscription.dispatch—(dispatch [:counter/inc])puts an event on the queue.
These are injected by reg-view. The body uses them just like the original re-frame's subscribe and dispatch. No ceremony. The @(...) syntax is Clojure's "unwrap this reactive value to its current contents" — it's how Reagent (the underlying view substrate) tracks dependencies.
Why register views?¶
Two reasons:
- AI inspection.
(rf/handlers :view)returns every registered view in the app. An AI can list, filter, and inspect them without parsing source files. - Hot reload that works. Re-evaluating the
reg-viewform replaces the registered view; mounted instances pick up the new code on next render.
There's a tradeoff: plain Reagent functions also work, but they don't get registry introspection. For a small app you can use plain defn views with no observable difference; reg-view is the safer default.
The mount¶
(defonce root
(rdc/create-root (js/document.getElementById "app")))
(defn ^:export run []
(rf/init! reagent-adapter/adapter) ;; wire the Reagent substrate
(rf/dispatch-sync [:counter/initialise]) ;; seed app-db before first render
(rdc/render root [counter]))
Four things happen here.
defonce root creates the React root once. The defonce matters: if the file is hot-reloaded, we want the existing root to survive so React can patch it in place rather than re-mount from scratch.
(rf/init! reagent-adapter/adapter) wires re-frame2 to the Reagent substrate. The runtime needs to know which view library it's driving (Reagent vs UIx vs Helix vs SSR), and init! is where that binding happens. We require re-frame.adapter.reagent :as reagent-adapter at the top of the file and pass its exported adapter Var. The call is idempotent — calling it twice is a no-op — so hot-reload is safe.
(rf/dispatch-sync [:counter/initialise]) runs the initialiser inline — by the time the next line runs, app-db is {:count 5}. The role of this call is covered above in Initialisation; we list it here for completeness because, in source order, it's part of run.
(rdc/render root [counter]) is the React/Reagent runtime asking "render this hiccup at this root." [counter] is hiccup referencing the Var that reg-view defed.
Without init!, the runtime has no adapter installed and the first subscribe / dispatch from a view would not know how to wire its reactivity to React. For the call shape across UIx, Helix, and SSR — and why the call is explicit at every call site — see chapter 19 — Adapters.
What just happened¶
When the page loads, here's what runs:
-
runis called.(rf/init! reagent-adapter/adapter)installs the Reagent substrate adapter into the runtime. -
(rf/dispatch-sync [:counter/initialise])runs the:counter/initialisehandler synchronously. The handler returns{:count 5}.app-dbis now{:count 5}. Thenrdc/rendermounts thecounterview at the root. -
The view's body runs.
@(subscribe [:count])returns5. The hiccup tree is[:div [:button "-"] [:span 5] [:button "+"]]. Reagent renders that as DOM. -
The user clicks
+. The button's:on-clickfires(dispatch [:counter/inc]). The event joins the queue. -
The runtime pops the event. The
:counter/inchandler runs: it reads{:count 5}, returns{:count 6}. The runtime updatesapp-db. The:countsubscription notices it changed. The view re-renders. The DOM shows6.
That's the entire dynamic story. Five steps, all named, no surprises.
Testing what we just built¶
The handler is a pure function — given db and event, return new db. That alone is testable directly:
(deftest counter-handlers
(let [inc-handler (:handler-fn (rf/handler-meta :event :counter/inc))
dec-handler (:handler-fn (rf/handler-meta :event :counter/dec))]
(is (= {:count 6} (inc-handler {:count 5} [:counter/inc])))
(is (= {:count 4} (dec-handler {:count 5} [:counter/dec])))))
That test runs on the JVM. There's no browser, no React, no runtime needed — handler-meta looks up the registered function, you call it with a value, you assert on the return. Tests like this run in milliseconds and you can have thousands of them. Chapter 13 — Testing covers the richer testing primitives (driving a full event through the dispatch loop, sub computation, fixtures) for when "call the handler as a function" isn't enough.
What the example covered¶
We touched every load-bearing primitive at least once:
- ✓ Three event handlers.
- ✓ A subscription.
- ✓ A view.
- ✓ A test.
What we didn't cover yet:
- Effects that aren't state changes — HTTP, navigation, localStorage. Coming in 04 — Events, state, and the cycle.
- State machines — for flows where "what's the next state?" is the load-bearing question. Coming in 08 — State machines.
- HTTP requests, the canonical way — the
:rf.http/managedfx with retry, abort, decode, and reply addressing. Coming in 10 — Doing HTTP requests.
A small extension¶
If you wanted the counter to also remember a history of past values, you'd:
- Change
:counter/initialiseto seed{:count 5 :history [5]}. - Change
:counter/incto update both:countand:history. - Add a
:historysubscription. - Display the history in the view.
That's it. The shape doesn't change. There's no new primitive to learn. Every change happens in the place you'd expect: a new event handler, a new sub, a new view fragment.
This is the real claim of re-frame2: the cost of new features is bounded by the size of the feature, not by the size of the app. In a poorly-shaped app, adding a feature requires reading a substantial fraction of the existing code to know where to wire it in. In a re-frame2 app, you read the events, the subs, and the view that touches the area you're changing, and that's enough.
For how this same counter looks under UIx and Helix, what init! is doing under the hood, and the slim-Reagent option for ship-size builds, see chapter 19 — Adapters.
Next¶
- 04 — Events, state, and the cycle — what the dynamic story looks like when handlers also produce side-effects, not just state changes.