Skip to content

app-db: one map, one write path

Your application's state has to live somewhere, and something has to change it. In re-frame2 it lives in app-db: one immutable Clojure map per frame. Event handlers return the next map; the event pipeline writes it.

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

A complete live counter

The counter from the Introduction gains a second fact, :step-size, and a decrement button. :initialise still takes the starting value and now seeds both facts. 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 [_ start]]
    {:db {:value start :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 " @(subscribe [:step-size]) ":"]
   [: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 0]]}
 [stepping-counter]]

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 payload map.

The events produce a sequence of complete map values:

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

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

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

Each event returns a replacement for the whole value. Outside this in-browser environment a real app also needs boot wiring: see the counter example or Boot and mount an app.

The write path

Everything your app keeps as state between events sits in one map of ordinary nested data, with no framework-imposed shape. A todo list, which later pages use, might hold:

{:todos   {1 {:id 1 :title "Buy milk"     :done? false}
           2 {:id 2 :title "Walk the dog" :done? true}}
 :showing :all}

There is one normal way to change it: dispatch an event; the handler returns an effect map that may include :db; the runtime commits that new map atomically. Handlers compute the next value; they do not mutate the old one.

(rf/reg-event :todo/toggle
  (fn [{:keys [db]} [_ id]]
    {:db (update-in db [:todos id :done?] not)}))

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.

To look at app-db from the REPL, call (rf/app-db-value :app). It returns the frame's current map, or nil when no frame has that id.

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.

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, so no spread-operator discipline is needed.

From re-frame v1

One app-db, and handlers return a new value, as in v1. app-db holds only your application data; framework bookkeeping lives next door in runtime-db. v1's reg-event-db and reg-event-fx are one reg-event that always returns an effect map.

Initial state is an event

Every frame starts with app-db = {}. There is no config option to seed it; seeding is itself an event, run through the same pipeline as every later change and listed under :initial-events on the frame (or frame-root).

The counter already did this with :initialise. A larger app is the same idea, and can list several events:

(rf/reg-event :todo/initialise
  (fn [_world _event]
    {:db {:todos {} :showing :all}}))

[rf/frame-root {:id :app
                :initial-events [[:todo/initialise]
                                 [:todo/add "Buy milk"]]}
 [todo-app]]

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. Prefer a named map payload when an initialise event carries options: [:initialise {:user-id 42}] with [_ {:keys [user-id]}].

When all you need is a literal starting map, the framework's own :rf/set-db event does it without a handler of yours:

[rf/frame-root {:id :app :initial-events [[:rf/set-db {:value 0 :step-size 1}]]}
 [stepping-counter]]

Frames covers the other frame options.

Shape the map around the domain

Use ordinary maps and vectors. Prefer stable domain paths (:todos, :showing) over scattering presentation flags next to every fact. Views stay thin; they read what they need through subscriptions. To have the runtime check that shape as it changes, register a schema for a path (Validate with schemas).

Store facts, derive conclusions

Put facts in app-db. Let subscriptions derive the conclusions views show (flows cover derivations you deliberately materialise back into app-db).

Good:

{:todos {1 {:id 1 :title "Buy milk"     :done? false}
         2 {:id 2 :title "Walk the dog" :done? true}}}

Poor:

{:todos           {...}
 :remaining-count 1
 :all-done?       false}

:remaining-count and :all-done? are conclusions. Stored next to the todos, they have to be updated by every handler that touches a todo, and one that forgets leaves them wrong. Derive them with a subscription instead.

Troubleshooting

Symptom Cause Fix
State "vanished" after a handler, and :rf.warning/db-nil-coerced is reported app-db is always a map, so a returned {:db nil} is coerced to {} Return the map you meant; to clear state on purpose, write {:db {}}
Two facts disagree A conclusion was stored next to its facts Derive in a subscription (or a flow if handlers must read it)
Initial UI shows empty values No seed event List an initialise event (or [:rf/set-db {…}]) in :initial-events
Frame creation throws :rf.error/initial-db-retired or :rf.error/on-create-retired The frame options carry :initial-db or :on-create Seed through :initial-events
Machine or route state doesn't reflect what you wrote into app-db Framework state lives in runtime-db, not app-db Dispatch the subsystem's events instead

Advanced

Yours, and the framework's next door

A running frame also holds runtime-db: framework bookkeeping (machine snapshots, the current route, the resource cache, …) under reserved :rf.runtime/* keys. The two partitions are separate: app-db is yours, and runtime-db is the framework's. Read runtime-db through the framework's subscriptions and change it by dispatching the framework's events; don't recreate its keys in app-db. An ordinary :db effect cannot wipe a machine snapshot. Frames goes deeper.

Keeping secrets out of traces

Tools and traces see a copy of app-db. To redact a path in that copy, return a classification effect beside :db. :sensitive redacts the value and :large replaces it with a size marker; :clear-sensitive and :clear-large undo them:

(rf/reg-event :auth/init
  (fn [{:keys [db]} _]
    {:db        (assoc db :auth {})
     :sensitive [[:auth :token] [:auth :refresh-token]]}))

Your handlers and subscriptions still read the real value. Classifying a path before anything is stored there is fine, so a seed event is the natural place. Keep secrets out of traces covers the details.