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-allreads(: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:
:rf.fx/clear-flowremoves the registration and the value at:output-path, so no stale value is left behind. Copy it elsewhere first if you need it.- 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, sodispatch-syncreturns with them settled.dispatchitself returns before the queued work runs. - Unlike a direct
reg-flowcall, 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
:dbnor any earlier flow's output in the same pass is written. - No
:rf.event/db-changedtrace fires, and:fxis 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/failedtrace 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/failedtrace also carries a:pathnaming 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
defnand 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. Theex-datacarries:cycle, the loop as a vector of ids: -
: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.
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.