Subscriptions and the Signal Graph

app-db is the root of a directed acyclic Signal Graph; view functions are the leaves; reg-sub creates the interior nodes. Nodes exist only while some rendered view needs them (created lazily, destroyed when unused). Two concurrent subscribes with = query vectors share one node — expensive computations run once for all consumers.

The four layers

  1. Ground truthapp-db.
  2. Extractors — subs reading directly from app-db. Rerun on every db change, so they must be computationally trivial (pluck a fragment). They are = circuit-breakers: if the fragment is unchanged, downstream never reruns.
  3. Materialised views — subs whose inputs are other subs (never app-db). All real computation (sort/filter/join/aggregate) goes here; only reruns when its input signals actually change.
  4. Views — subscribe to layer 2/3, produce hiccup only.

A node reruns when inputs change but propagates only if its output differs (=) from last time.

reg-sub forms

Layer 2 — input defaults to app-db:

(reg-sub
 :cart/items
 (fn [db query-v]          ;; query-v = full vector passed to subscribe
   (get-in db [:cart :items])))

Layer 3 — canonical form with explicit signals fn:

(reg-sub
 :cart/summary
 (fn [query-v]                                     ;; signals fn: one signal, vector, or map of signals
   [(subscribe [:cart/items]) (subscribe [:cart/coupon])])
 (fn [[items coupon] query-v]                      ;; computation fn: inputs first
   (calculate items coupon)))

:<- sugar (one pair per input signal):

(reg-sub
 :cart/summary
 :<- [:cart/items]
 :<- [:cart/coupon]
 (fn [[items coupon] _] (calculate items coupon)))

;; single input — no vector destructuring:
(reg-sub
 :cart/item-count
 :<- [:cart/items]
 (fn [items _] (count items)))

Computation-fn sugar

  • :-> f — computation fn is 1-arity, receiving only input values (query vector omitted). Ideal for keyword/path accessors: (reg-sub :auth/token :-> #(get-in % [:auth :token])). Never pass a bare keyword as the 2-arity computation fn — a keyword called with 2 args returns the second arg (the query vector) when the key is missing; :-> avoids this trap.
  • :=> f — computation fn called as (f input-values & query-args), spreading the query vector's tail as args: (reg-sub :cart/item :<- [:cart/items] :=> (fn [items id] (get items id))) for (subscribe [:cart/item 42]).

Parameterised subs: the whole query vector reaches the handler — (subscribe [:cart/items-by-colour "blue"]), (fn [db [_ colour]] ...).

Rules

  • Extract computation fns as named defns (in .cljc) and register them — directly testable:
(defn visible-todos [[todos showing] _] ...)
(reg-sub :todos/visible :<- [:todos/all] :<- [:todos/showing] visible-todos)
  • No generic path subscription ((reg-sub :get (fn [db [_ path]] (get-in db path)))) — views become coupled to db structure; restructuring app-db breaks every view. Per-key extractors are the right kind of repetition.
  • Never subscribe outside a render fn (event handlers, callbacks) — leaks (only render fns give Reagent a disposal hook). re-frame.alpha's sub is memory-safe anywhere, at recomputation cost.
  • Classic no-rerender bug: handler writes to a path different from the one layer 2 reads → layer 2 output = unchanged → layer 3 cache hit → view never updates. When a view "doesn't update", walk the layer 2 → 3 chain comparing pre/post values to find which equality gate held.
  • Form-2 gotcha: (let [v @(subscribe ...)] (fn [] v)) freezes the value. Keep the subscription in the outer let and deref in the inner fn: (let [v* (subscribe ...)] (fn [] @v*)).
  • defmulti views: a defmethod returning a fn silently becomes form-2 and Reagent caches it forever — keep all methods form-1, or use case/cond.

Performance

  • Reagent decides re-render via = on props: give each child only the data it needs (don't pass the whole app state to 300 children); split big components so rows subscribe to row data.
  • Use ^{:key ...} on list items.
  • Inline (fn [e] ...) handlers are new each render; if a component produces lots of DOM, lift handlers into a form-2 outer let. Don't over-optimize otherwise.
  • Diagnose with re-frame-10x tracing.