Skip to content

05 — Flows

A flow is a piece of derived state. You declare its inputs (other paths or subs), its computation (a pure fn over those inputs), and the path in app-db it should write its output to. The runtime watches the inputs, recomputes the output when any of them change, and writes the result. It's a sub-with-a-side-effect-on-app-db — useful exactly when you want derived state that other code reads via plain get-in (or a plain sub), without having to remember to recompute it manually.

Flows replace v1's on-changes interceptor and a chunk of what reg-sub-raw was used for. They also replace much of what enrich did. The point: derived state is now a registered, named, observable, restorable thing, not a closure hidden behind an interceptor.

The normative source is 013-Flows.md.

The flow surface

reg-flow

  • Kind: function
  • Signature:
    (reg-flow flow)
    (reg-flow flow opts)
    
  • Description: Register a flow. The flow map carries :id, :inputs, :output, :path. opts is a map (currently {:frame frame-id}) selecting the owning frame. Returns the flow's :id (per the reg-* return-value convention).

clear-flow

  • Kind: function
  • Signature:
    (clear-flow id)
    (clear-flow id opts)
    
  • Description: Deregister the flow from the named frame and dissoc-in its :path from that frame's app-db only. Sibling frames' state is preserved.

A minimal flow

(rf/reg-flow
  {:id     :cart/subtotal
   :inputs [[:cart :items] [:tax :rate]]      ;; vector of app-db paths
   :output (fn [items rate]                    ;; values arrive positionally
             (let [subtotal (reduce + (map :price items))]
               (* subtotal (+ 1 rate))))
   :path   [:cart :subtotal]})

;; Now [:cart :subtotal] in app-db is always derived. Read it via a plain
;; sub or a plain handler. Adding an item triggers recompute; updating the
;; rate triggers recompute; nothing else writes [:cart :subtotal].

:inputs is a positional vector of app-db paths; the values at those paths arrive as positional args to :output in the same order (a map-keyed :inputs form is a deferred design option per Spec 013 §Open questions, not v1). The shape is data — no fn registration with a separate id, no interceptor wiring, no closure capture. The conformance harness validates flows by walking the registered data and applying it to a synthesised app-db.

The flow map

Key Required Notes
:id yes The registry key. Use a namespaced keyword.
:inputs yes A vector of app-db path vectors. Read positionally; the value at each path is passed to :output in order.
:output yes (fn [in-1 in-2 ...] new-output). Pure; receives the input values positionally; called every time inputs change; must be deterministic.
:path yes Where to write the output in app-db.
:doc no One-sentence what-and-why; surfaces in tooling.
:schema no Malli schema for the output value. Validated on every recompute in dev (and elided in production, like all schema validation). On failure the runtime emits :rf.error/schema-validation-failure :where :flow-outputobservational: the output is still written (a flow output is materialised state downstream already reads), the trace surfaces the producer bug. Routes through the registered validator, so an app without the schemas artefact pays nothing. See Spec 013 §Flow output validation.

Frame-scoping

The :flow registrar slot is last-registration-wins across frames — registering the same id against multiple frames shares one registrar slot keyed by flow-id only. For full per-frame discovery use @re-frame.flows/flows directly; the per-frame runtime registry is the source of truth for evaluation. See 013 §Frame-scoping.

Frame-destroy teardown

destroy-frame! releases every per-frame piece of flow state — registry slot, last-inputs rows, registrar entries for ids the destroyed frame was last owner of. Sibling frames' state is preserved. See 013 §Frame-destroy teardown.

Runtime registration via :fx

Sometimes you want to register or clear a flow from inside an event handler — feature-flag gates, demand-driven registration, the SSR per-request frame setting up flows for the request and tearing them down on response. Two reserved fx-ids cover that:

[fx-id args] Args Status Intuition
[:rf.fx/reg-flow flow-map] a flow map (same shape as reg-flow) v1 Register a flow at runtime via :fx.
[:rf.fx/clear-flow id] flow id v1 Clear a registered flow at runtime via :fx.

The signature mirrors reg-flow / clear-flow exactly — same opts, same return semantics. Use whichever surface matches your call site.

Failure semantics

Production-survivable. A throw inside a flow's :output fn surfaces as :rf.error/flow-eval-exception on the always-on error-emit substrate — registered :on-error policy fns and register-error-listener! callbacks fire under CLJS :advanced + goog.DEBUG=false. The error is not trace-only; production deployments catch it.

See Spec 013 §Failure semantics rule 4 and Spec 009 §Production builds.

Flow vs sub: when to reach for which

  • Sub when the value is computed on read and never written back to app-db. Cached; ref-counted; lazy.
  • Flow when the value should live in app-db — because other code reads it via path, other flows depend on it via path, or you want it captured by the epoch snapshot for time-travel.

A useful rule: if you find yourself writing (reg-sub ::derived-total (fn [...])) and immediately needing to read its value from a plain handler via subscribe-once, that's a flow. If you find yourself writing a flow that no path-shaped consumer ever reads (only sub-shaped ones do), it should probably be a sub.

See also