Skip to content

app-db: one map, one write path

The introduction showed how re-frame2 computes. This page follows the application data through that loop: where it lives and how it changes through events.

app-db is the application state for one frame: one immutable Clojure map. Handlers return a description of the next map; the event pipeline is what actually writes.

event + world → handler → {:db next-db} → atomic commit

One map. One write path. Events in, new map out.

Almost everything else in re-frame2 stays simple because of that.

A complete live counter

In the intro, the frame's app-db started as {} and the display showed 0 only because the subscription fell back when :value was missing. After the first click, app-db became {:value 1}.

This page makes the initial value explicit through an :initialise event, then adds a second fact — :step-size — in the same map. Still one store. Click the buttons (edit the cell and press Ctrl-Enter / Cmd-Enter if you change the code):

(require '[re-frame.core :as rf])

(rf/reg-event :initialise
  (fn [_world _event]
    {:db {:value 0 :step-size 1}}))

(rf/reg-event :step-size/set
  (fn [{:keys [db]} [_ {:keys [step-size]}]]
    {:db (assoc db :step-size step-size)}))

(rf/reg-event :inc
  (fn [{:keys [db]} _]
    {:db (update db :value + (:step-size db))}))

(rf/reg-event :dec
  (fn [{:keys [db]} _]
    {:db (update db :value - (:step-size db))}))

(rf/reg-sub :value (fn [db _] (:value db)))
(rf/reg-sub :step-size (fn [db _] (:step-size db)))

(rf/reg-view stepping-counter []
  [:div
   [:button {:on-click #(dispatch [:dec])} "−"]
   [:span " " @(subscribe [:value]) " "]
   [:button {:on-click #(dispatch [:inc])} "+"]
   [:span {:style {:margin-left "1.5em"}} "step:"]
   [:button {:on-click #(dispatch [:step-size/set {:step-size 1}])} "1"]
   [:button {:on-click #(dispatch [:step-size/set {:step-size 10}])} "10"]])

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

The handler parameters use destructuring. {:keys [db]} takes app-db from the world map. [_ {:keys [step-size]}] ignores the event id and takes :step-size from the event's payload map.

No second store. The events produce a sequence of complete map values:

[:initialise]
;; => {:value 0 :step-size 1}

[:step-size/set {:step-size 10}]
;; => {:value 0 :step-size 10}

[:inc]
;; => {:value 10 :step-size 10}

The seed is not a lone {:value 0} that later grows a second key by accident. Both facts begin in the initial map; later events return replacements for that whole value.

That is a complete live counter: registrations, a frame-root that creates and scopes the frame and runs the seed, and a view. (The playground mounts the trailing hiccup; a real app still needs boot wiring — counter example or Boot and mount an app.) The rest of the guide grows this same counter one concept at a time.

One map, one write path

Everything your app owns as state between events sits in one map — ordinary nested data, with no framework-imposed shape:

{:user {:id 42 :name "Mike"}
 :cart {:items [] :status :draft}
 :ui   {:active-panel :cart :modal nil}}

Exactly one normal write path: dispatch an event; the handler returns an effect map that may include :db; the runtime commits that new map atomically. Handlers compute a proposed next value — they do not mutate the old one.

(rf/reg-event :cart/add
  (fn [{:keys [db]} [_ {:keys [item]}]]
    {:db (update-in db [:cart :items] conj item)}))

update-in returns a new map. The runtime later moves the app-db reference from the old value to the new one in a single commit. (The place is app-db; the value currently in it is usually bound as db.)

That gives you three useful properties:

  • no view sees a half-written state;
  • old values can be inspected, diffed, or restored;
  • handlers are pure functions you can unit test.

A handler may return no :db key (only :fx, say) and leave app-db alone, or return the same db object it was handed so the runtime skips a no-op write.

Gotcha — {:db nil}

app-db is always a map. An accidental {:db nil} is coerced to {} with a dev warning (:rf.warning/db-nil-coerced). To clear state on purpose, write {:db {}}.

Coming from Redux?

app-db is the single store; a handler is a pure function that returns the next state as data ({:db …}), and the runtime commits it. No combined reducers, no prescribed slice shape — one ordinary Clojure map. Immutability is by construction (update-in cannot mutate), not a spread-operator discipline.

From re-frame v1

One app-db, handlers return a new value — same spirit. What changed: app-db is only your application data. Framework bookkeeping lives next door in runtime-db, not under a :rf/runtime root inside your map.

Initial state is an event

A frame's event fold starts with app-db = {}. There is no :db config slot to seed it. Seeding is itself an event — the same pipeline as every later change — listed as :initial-events on the frame (or frame-root).

The counter already did this with :initialise. A larger app is the same idea: a domain handler that returns the first map, then any follow-ups you need:

(rf/reg-event :initialise
  (fn [_world _event]
    {:db {:session nil
          :ui {:route :home}}}))

[rf/frame-root {:id :app
                :initial-events [[:initialise]
                                     [:session/restore]]}
 [root-view]]

Each initial event's pipeline runs synchronously, in order, through its immediate commit before the next begins. If a handler starts asynchronous work, its reply arrives later through another event; setup does not wait for the host operation. By the time setup finishes, app-db includes the immediate :db commits from the initial events — no seeding side channel. Prefer a named map payload when an initialise event carries options: [:initialise {:user-id 42}] with [_ {:keys [user-id]}]. Frames covers the rest of the registration grammar.

From re-frame v1

:initial-db and :on-create are gone. Seed with :initial-events and your own initialise event (or several), not a config map.

Shape the map around the domain

Use ordinary maps and vectors. Prefer stable domain paths (:session, :articles, :ui) over scattering presentation flags next to every fact. Views stay thin; they read what they need through subscriptions.

Store facts. Derive conclusions.

Put facts in app-db. Let subscriptions derive view-facing conclusions (later, flows cover derivations you intentionally materialise back into app-db).

Good:

{:cart {:items [{:sku "A" :price 10}
                {:sku "B" :price 20}]}}

Poor:

{:cart {:items [...]
        :total 30
        :empty? false}}

total and empty? are conclusions. If you store them, they can drift from the items. Derive them so there is only one truth to update — that is the next page's job.

Yours, and the framework's next door

For state your application owns between events, app-db is the write target. A running frame also holds runtime-db — framework bookkeeping (machine snapshots, route, resource cache, …) under reserved :rf.runtime/* keys. Two partitions: app-db is yours; runtime-db is the framework's (read it via its subscriptions; influence it by dispatching its events — never forge it by hand in app-db). An ordinary :db effect cannot wipe a machine snapshot. Frames goes deeper.