Skip to content

Flows: derived values your handlers can read

Most derived values are subscriptions. But a subscription's value lives in a cache that only views read. An event handler can't reach it, and neither can an app-db validation schema.

When a derived value needs to be plain data in app-db, use a flow: a registered rule that says "when these paths change, run this pure function and write the result into app-db." If only views read the value, keep the subscription.

Your first flow

Subscriptions derived :todo/remaining-count for the footer. Now a handler needs it too: "toggle all" should mark every todo done if any remain, and mark them all active otherwise. As a flow, the count lives in app-db where that handler can read it.

app-db warned against storing :remaining-count, because a handler that forgets to update it leaves it wrong. A flow is the exception: the runtime rewrites it whenever :todos changes, so no handler can forget.

;; Flows ship in the day8/re-frame2-flows artefact: require re-frame.flows
;; once, anywhere in your app. You still call reg-flow through rf.
;; a flow registers into one frame: see "A flow belongs to a frame" below
(rf/reg-flow :todo/remaining-count
  {:doc         "How many todos are not done, kept in app-db."
   :inputs      [[:todos]]               ;; app-db paths to watch
   :output-path [:remaining-count]}      ;; where the result is written
  (fn [todos] (count (remove :done? (vals todos)))))

Read it top to bottom: watch [:todos]; when it changes, run the function; write the result to [:remaining-count]. The function, the third argument, receives one argument per :inputs path, in order.

Here it is running:

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

(rf/reg-event :todo/initialise
  (fn [_ _]
    {:db {:todos   {1 {:id 1 :title "Buy milk"     :done? false}
                    2 {:id 2 :title "Walk the dog" :done? true}}
          :showing :all}}))

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

(rf/reg-event :todo/toggle-all
  (fn [{:keys [db]} _]
    (let [done? (pos? (:remaining-count db))]   ;; the flow's output, read as plain data
      {:db (update db :todos
                   (fn [todos]
                     (reduce-kv (fn [m id t] (assoc m id (assoc t :done? done?)))
                                {} todos)))})))

;; create the frame first; the flow below is registered into it
(rf/make-frame {:id :app})

;; the new idea: a derived value written INTO app-db
(rf/reg-flow :todo/remaining-count
  {:inputs      [[:todos]]
   :output-path [:remaining-count]
   :frame       :app}                     ;; a flow belongs to one frame
  (fn [todos] (count (remove :done? (vals todos)))))

(rf/dispatch-sync [:todo/initialise] {:frame :app})

(rf/reg-sub :todo/todos (fn [db _] (:todos db)))
(rf/reg-sub :todo/all {:inputs [[:todo/todos]]}
  (fn [[todos] _] (vec (sort-by :id (vals todos)))))

;; the count is now a plain app-db read
(rf/reg-sub :todo/remaining-count
  (fn [db _] (:remaining-count db)))

(rf/reg-view todo-list []
  [:div
   [:button {:on-click #(dispatch [:todo/toggle-all])} "Toggle all"]
   [:ul
    (for [{:keys [id title done?]} @(subscribe [:todo/all])]
      ^{:key id}
      [:li {:on-click #(dispatch [:todo/toggle id])
            :style    {:text-decoration (when done? "line-through")}}
       title])]
   [:p @(subscribe [:todo/remaining-count]) " left"]])

[rf/frame-provider {:frame :app}
 [todo-list]]

Every event that changes :todos also recomputes :remaining-count, in the same write. A pipeline run installs all of its app-db changes as one write, the commit. Flows run after the handler and before that commit, so the handler's change and the flow's output land together. A view never sees a toggled todo next to a stale count.

A flow skips the recompute when its inputs are unchanged: an event that writes the same :todos back doesn't run it.

What changed, and what didn't

  • The view doesn't change. It still reads @(subscribe [:todo/remaining-count]). Only the sub's body changes, from computing the count to reading it. Flows don't register subscriptions of their own; anything that reads app-db can read the output path.
  • Handlers can read it. :todo/toggle-all reads (:remaining-count db) like any other key. A handler sees the value as of the last completed event; if the handler itself changes :todos, the recompute happens after it, in the same commit.
  • You never write the output path. Handlers keep writing :todos. The runtime is the only writer of [:remaining-count], and Xray attributes each write to the flow that made it.
From re-frame v1

A flow replaces on-changes: the same recompute-on-input-change behaviour, but registered with the runtime instead of added to particular events' interceptor chains. Because it is registered separately, a flow can be switched on and off at runtime (Toggling a derivation at runtime).

Coming from SQL?

A flow is a materialised view that refreshes itself. Instead of deciding when to run REFRESH MATERIALIZED VIEW, the flow re-runs whenever its inputs change, as part of the write that changed them, so it is never stale.

A flow belongs to a frame

Unlike a subscription, a flow belongs to one frame, because it writes that frame's app-db. That is why the example passes :frame :app. You can instead register it inside a frame scope (with-frame, see Frames), or from an event handler with the :rf.fx/reg-flow effect (below), which uses the handler's frame. With neither a scope nor :frame, reg-flow raises :rf.error/no-frame-context.

In an app mounted with frame-root, the frame doesn't exist yet when your namespaces load. Register the flow from the seed event, which runs inside the frame:

(def remaining-count-flow
  [:todo/remaining-count
   {:doc         "How many todos are not done, kept in app-db."
    :inputs      [[:todos]]
    :output-path [:remaining-count]}
   (fn [todos] (count (remove :done? (vals todos))))])

(rf/reg-event :todo/initialise
  (fn [_ _]
    {:db {:todos {} :showing :all}
     :fx [[:rf.fx/reg-flow remaining-count-flow]]}))

[rf/frame-root {:id :app :initial-events [[:todo/initialise]]} …] then mounts the app as on every earlier page.

A flow registered directly first computes on the next event in its frame, which is why the live example registers it before dispatching :todo/initialise.

In Xray, a toggle's event row shows the handler's change and, in the same commit, the flow's write to [:remaining-count]. Restore an older epoch and the count goes back with the rest of app-db, because it is ordinary state.

The registration, slot by slot

reg-flow takes three arguments: (reg-flow flow-id metadata derive-fn). The flow id is a namespaced keyword, like event and sub ids. The derive fn is a pure function of the input values that returns the output. (A :derive key in the metadata map is a registration error; the function goes in the third argument.)

The metadata map holds the rest; :inputs and :output-path are required:

Key Required? Meaning
:inputs yes A vector of paths to watch. A plain path reads app-db; a path starting with :rf.db/runtime reads runtime-db (below). Values reach the derive fn in this order.
:output-path yes The app-db path the result is written to. A flow never writes runtime-db.
:doc no One sentence on what and why. Shown in Xray and other tools.
:frame no The target frame, when registering outside any frame scope.
:schema no A Malli schema for the output, checked in dev on every recompute (Validating a flow's output).
:sensitive no Output subpaths to redact in traces (Classifying a flow's output).
:large / :large? no Output subpaths, or with :large? true the whole output, too big to send to off-box tools.

The reg-flow macro records the source location for you. It returns the flow id, like the rest of the reg-* family.

When a derivation earns app-db

Use a flow only when all of these hold:

  • The value is part of the application's state, not just something a view renders.
  • Event handlers, other flows, or registered schemas need to read it as plain app-db data.
  • It should survive SSR hydration, time-travel restore, and app-db serialisation. A subscription cache is not sent to the client.
  • The derivation is stable enough to register, not a one-off calculation inside a single handler.

Flows may read each other's outputs. The runtime orders dependent flows and rejects cycles and overlapping output paths at registration (below).

A typical app has dozens of subscriptions and a handful of flows at most:

Signal Prefer
Only views use the value a subscription
One handler needs it once compute it in that handler
Named stages or a lifecycle a machine
Not sure Where should this value live?
Going deeper

A subscription and a flow are the same kind of node in one derivation graph: a pure function with a different policy, computed on demand versus written into app-db after each event. See One graph: derivations and their algebra views.

Troubleshooting

Symptom Cause Fix
:rf.error/flows-artefact-missing on the first flow call The flows artefact isn't loaded Add day8/re-frame2-flows and require re-frame.flows once
The output path never appears and nothing is reported :rf.fx/reg-flow ran without the flows artefact, so the effect did nothing Add day8/re-frame2-flows and require re-frame.flows once
:rf.error/no-frame-context from reg-flow Registered outside any frame scope Pass :frame, wrap in with-frame, or use :rf.fx/reg-flow from a handler
:rf.error/flow-frame-not-live from a top-level reg-flow :frame names a frame frame-root hasn't created yet Register it from the seed event with :rf.fx/reg-flow
Output path is nil right after registering A directly registered flow first computes on the next event in its frame Dispatch an event after registering, or use :rf.fx/reg-flow
:rf.error/flow-path-overlap Two flows write the same path, or one path is a prefix of the other Give each flow its own output path
:rf.error/flow-eval-exception and the event is dropped The derive fn threw, or its result can't be written at :output-path Read the record's :phase (below)

Advanced

Toggling a derivation at runtime

Flows can be registered and removed while the app runs, with two built-in effects: :rf.fx/reg-flow, given the same [id metadata derive-fn] triple, and :rf.fx/clear-flow, given an id. Use them for derivations that should run only while something is switched on, like an optional progress bar:

(def progress-flow
  [:todo/progress
   {:inputs      [[:todos]]
    :output-path [:progress]}
   (fn [todos]
     (if (empty? todos)
       0
       (/ (count (filter :done? (vals todos))) (count todos))))])

(rf/reg-event :todo/show-progress
  (fn [_ _] {:fx [[:rf.fx/reg-flow progress-flow]]}))

(rf/reg-event :todo/hide-progress
  (fn [_ _] {:fx [[:rf.fx/clear-flow :todo/progress]]}))

Notes:

  1. :rf.fx/clear-flow removes the registration and the value at :output-path, so no stale value is left behind. Copy it elsewhere first if you need it.
  2. After the effect vector finishes, the runtime queues one [:rf/settle-flows] event ahead of ordinary follow-up events. It writes the new outputs or removes cleared ones before the drain finishes, so dispatch-sync returns with them settled. dispatch itself returns before the queued work runs.
  3. Unlike a direct reg-flow call, the effect schedules that first evaluation. Inputs must already be in app-db or be written by the registering event. The settle is a separate commit: if its derive throws, it cannot undo the registering event's already-committed state.

Outside a handler, in boot code, a test, or per-tenant setup, use the functions directly:

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

;; progress-flow is a [id metadata derive-fn] triple, so apply it:
(rf/with-frame :todos/work
  (apply flows/reg-flow progress-flow))

;; or name the frame in the metadata:
(let [[id metadata derive-fn] progress-flow]
  (rf/reg-flow id (assoc metadata :frame :todos/work) derive-fn))

(rf/clear :flow :todo/progress {:frame :todos/work})

flows/reg-flow is the function form of the rf/reg-flow macro; a macro can't be applyd on the JVM. rf/clear :flow returns after removing the output path and recomputing anything that read it, so the next line sees current app-db. On an absent frame it does nothing, so teardown can be repeated safely. Its options map accepts only :frame; a misspelled key throws :rf.error/registrar-clear-bad-request. There is no flows/clear-flow.

Re-registering a flow (and hot reload)

Calling reg-flow again with a registered id, on the same frame, replaces the definition. The flow re-runs on the next event even if its inputs didn't change, and the dependency order is recomputed. That is what makes hot reload work: edit a derive fn, save, and the running app uses it.

If the replacement moves :output-path, the old path is removed from app-db. Keep the same path and the next recompute overwrites it in place.

Deriving from route or machine state

A flow's :inputs can also read runtime-db, the frame's other state map, where the framework keeps route state and machine snapshots (the two partitions). A path that starts with :rf.db/runtime reads runtime-db:

(rf/reg-flow :todo/on-done-route?
  {:doc         "True while the router shows the done-todos route."
   :inputs      [[:rf.db/runtime :rf.runtime/routing :current :route-id]]
   :output-path [:on-done-route?]}                    ;; written to app-db, as always
  (fn [route-id] (= route-id :todo.route/done)))

The output still goes to app-db. The flow re-runs when either partition changes, so a navigation that changes only runtime-db still updates it. There is no [:rf.db/app …] form: a plain path always reads app-db.

Validating a flow's output

:schema declares a Malli schema for the output. In dev, every recompute is checked against it:

(rf/reg-flow :todo/remaining-count
  {:inputs      [[:todos]]
   :output-path [:remaining-count]
   :schema      [:int {:min 0}]}        ;; a non-negative integer
  (fn [todos] (count (remove :done? (vals todos)))))

A violation does not throw and does not undo the write. The value is written, the commit proceeds, and the failure is reported as a :rf.error/schema-validation-failure error record with the flow id, the output path, the value, and Malli's explanation. A later flow in the same pass may already have used the value, so undoing one write would leave the rest inconsistent. (A derive fn that throws is different; it aborts the event, below.)

The check is dev-only and is elided from production builds (what goes and what stays). It also needs the schemas artefact; without it, the check passes and costs nothing.

Classifying a flow's output

A flow's output goes out on the trace stream to Xray and any connected monitor. If part of it is sensitive (a token) or large (a big report), classify it, as with other data classification:

(rf/reg-flow :auth/derived-session
  {:inputs      [[:auth :raw-claims]]
   :output-path [:auth :session]
   :sensitive   [[:token]]          ;; redact :token in traces
   :large       [[:audit-log]]}     ;; leave :audit-log out of off-box traces
  (fn [claims] (build-session claims)))

:sensitive and :large are each a vector of subpaths into the output. [[]] classifies the whole output, and :large? true is shorthand for that. :sensitive true and :sensitive? true are wrong on a flow; a malformed declaration is rejected at registration with :rf.error/flow-bad-marks.

Classification does not pass from inputs or from the triggering event to a flow's output. A flow that reads a sensitive slice must classify its own output, as above. Event registrations use their own :sensitive paths; the old boolean :sensitive? does not classify an event's payload. See Keep secrets out of traces.

What happens when a derive throws

A derive fn is pure, but it can still throw, for example on a nil where it expected a number. Like any other failure before the commit, the whole event aborts:

  • App-db is unchanged. Neither the handler's :db nor any earlier flow's output in the same pass is written.
  • No :rf.event/db-changed trace fires, and :fx is skipped: no child dispatches, no requests.
  • The failure is reported as :rf.error/flow-eval-exception, with the flow id, the event, and a :phase. It goes to the always-on error listeners, so a production error monitor receives it. In dev builds a more detailed :rf.flow/failed trace fires first.
  • The event is not retried. Every flow re-evaluates on the next event.

The same applies to a throw in a coeffect supplier, the handler, or an interceptor: an event commits in full or not at all.

A flow can also fail after the derive fn returns, when the result can't be written. If app-db holds a vector at [:report :totals] and the output path is [:report :totals :net], the write throws. :phase tells the two apart:

  • :phase :derive: the derive fn threw. Fix it to handle the inputs it receives.
  • :phase :output-write: the result couldn't be written. Fix :output-path, or the shape of app-db at its parent. The dev-only :rf.flow/failed trace also carries a :path naming the output path.

All-or-nothing covers everything up to the app-db write, not :fx. Once app-db has committed, effects run best-effort (Effects). To undo across an effect, as with an optimistic update, dispatch a compensating event from :on-failure.

Testing a flow

Test a flow in two parts:

  • The derive fn is a pure function. Lift it into a named defn and call it with literal inputs, as you would test a handler.
  • The wiring, meaning inputs watched, output written, same-commit timing, tests through a real frame: register the flow, dispatch an event that writes an input, and read the output path.
(deftest remaining-count-updates-with-the-write
  (rf/with-new-frame [f (rf/make-frame {})]
    (rf/reg-flow :todo/remaining-count      ;; the frame comes from with-new-frame
                 {:inputs      [[:todos]]
                  :output-path [:remaining-count]}
                 (fn [todos] (count (remove :done? (vals todos)))))
    (rf/dispatch-sync [:rf/set-db {:todos {1 {:id 1 :title "Buy milk" :done? false}}}])
    (is (= 1 (:remaining-count (rf/app-db-value f))))))

with-new-frame makes the frame current for the body, so reg-flow needs no :frame, and destroys it on exit so nothing leaks into the next test. There is nothing to wait for: the flow computes in the dispatched event's commit.

When the framework refuses: the registration-time errors

A direct reg-flow call throws before installing an invalid registration. Through :rf.fx/reg-flow, the same failure is reported as an effect failure and that entry is skipped; the event's prior commit stands and later effects still run. Common registration errors are:

  • :rf.error/flow-cycle: flow A reads B's output and B reads A's, directly or through a chain. The ex-data carries :cycle, the loop as a vector of ids:

    (rf/reg-flow :a {:inputs [[:b]] :output-path [:a]} identity)
    (rf/reg-flow :b {:inputs [[:a]] :output-path [:b]} identity)
    ;; throws; (ex-data e) includes {:rf.error/id :rf.error/flow-cycle :cycle [:a :b :a]}
    
  • :rf.error/flow-path-overlap: two flows in one frame whose output paths are equal or one is a prefix of the other. They would overwrite each other in no defined order, so the second is rejected. Sibling paths such as [:x :y] and [:x :z] are fine.

    (rf/reg-flow :a {:inputs [[:w]] :output-path [:x]} identity)
    (rf/reg-flow :b {:inputs [[:h]] :output-path [:x]} identity)
    ;; throws; (ex-data e) includes {:rf.error/id :rf.error/flow-path-overlap}
    

A malformed registration throws an error naming the argument: a nil or non-keyword id, bad :inputs or :output-path, a :derive key in the metadata, or a malformed classification. The reg-flow reference lists each id.

Without the day8/re-frame2-flows artefact, or if nothing has required re-frame.flows, the first reg-flow or rf/clear :flow throws :rf.error/flows-artefact-missing, naming the calling function. The :rf.fx/reg-flow and :rf.fx/clear-flow effects do nothing instead. The schemas, machines, and routing artefacts work the same way.