Coeffects: the way in¶
Effects handle work going out: a handler returns descriptions and the
runtime performs them. This page covers the other direction, the facts a handler
reads from the world: the current time, a value in localStorage, a fresh id.
Reading those inside the handler would make it impure. Instead, the handler declares what it needs and the runtime delivers the values as inputs called coeffects. A fact registered as recordable is stored with the event, so replaying the event reproduces the run exactly.
Stamping todos with the time¶
Each todo should show when it was added. The handler must not read the clock itself:
;; Don't do this
(rf/reg-event :todo/add
(fn [{:keys [db]} [_ title]]
(let [id (inc (apply max 0 (keys (:todos db))))]
{:db (assoc-in db [:todos id] {:id id :title title :done? false
:created-at (js/Date.now)})})))
Called twice with the same inputs, it returns different state. A test can't pin it down without patching the global clock, and replaying the event tomorrow stamps tomorrow's time.
Instead, declare the fact and receive it as a plain value:
(require '[re-frame.core :as rf])
(rf/reg-event :todo/initialise
(fn [_ _] {:db {:todos {} :showing :all}}))
;; the new idea: declare a fact from the world, receive it as data
(rf/reg-event :todo/add
{:rf.cofx/requires [:rf/time-ms]}
(fn [{:keys [db rf/time-ms]} [_ title]]
(let [id (inc (apply max 0 (keys (:todos db))))]
{:db (assoc-in db [:todos id]
{:id id :title title :done? false :created-at time-ms})})))
(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)))))
(rf/reg-view todo-list []
[:div
[:button {:on-click #(dispatch [:todo/add (rand-nth ["Buy milk" "Walk the dog" "Call mum"])])}
"Add a todo"]
[:ul
(for [{:keys [id title created-at]} @(subscribe [:todo/all])]
^{:key id}
[:li title
[:span {:style {:color "#888" :margin-left "1em"}}
"added at " created-at " ms"]])]])
[rf/frame-root {:id :app :initial-events [[:todo/initialise]]}
[todo-list]]
Notes:
- The handler has the same shape as before. The only addition is
:rf.cofx/requires [:rf/time-ms]in the metadata map, and the value arrives in the handler's first argument, the world map, under its own id. - The runtime reads the clock once, when the event is queued, and records the value
with the event. Replay the event next week and
:created-atcomes out the same. - App-db stores raw milliseconds. Formatting them for people belongs in a subscription (Views); this view shows the raw number to keep the example short.
:rf/time-ms is the one fact core provides. Everything else you register yourself.
Loading saved todos¶
Effects saved the todos to localStorage. On startup,
:todo/initialise should load them. Reading storage in the handler has the same
problem as reading the clock: replay would re-read whatever storage holds now, not
what it held then. So register the read as a coeffect with
reg-cofx:
;; cf. examples/core/todomvc/db.cljs
(ns todo.storage
(:require [cljs.reader :as reader]
[re-frame.core :as rf]))
(rf/reg-cofx :todo.storage/todos
{:doc "The saved todos, read from localStorage."
:recordable? true}
(fn []
(or (some-> (.-localStorage js/globalThis)
(.getItem "todos")
(reader/read-string))
{})))
(rf/reg-event :todo/initialise
{:rf.cofx/requires [:todo.storage/todos]}
(fn [{:keys [todo.storage/todos]} _]
{:db {:todos todos :showing :all}}))
The supplier is a plain function that returns the value. The runtime calls it when
:todo/initialise starts processing, records the result with the event, and hands
it to the handler. The handler stays pure: given the same stored todos, it always
builds the same app-db.
:recordable? true is what makes replay safe. The saved todos decide what goes into
app-db, so the value must be recorded, and replay must reuse the recorded value
rather than read storage again. Two grades
below explains the choice.
The pair is symmetrical: the effect :todo.storage/save is the only code that writes
storage, and the coeffect :todo.storage/todos is the only code that reads it.
From re-frame v1
[(rf/inject-cofx :local-store "k")] in the interceptor vector becomes
:rf.cofx/requires [[:local-store "k"]] in the metadata map, and the supplier
returns the value instead of updating a context. inject-cofx is removed with no
alias: re-frame.core has no such var, so a leftover call fails to compile. Coeffects are delivered before the
interceptor chain runs, so no interceptor sees a half-filled world map. And
because there is only one reg-event, every handler can declare requirements
(v1's reg-event-db could not). See the migration guide.
The world map¶
The {:keys [db]} you destructure in every handler is the
world map, and everything in it is a coeffect. :db and :event are always there, alongside framework context such as
:rf.frame/id, :rf.db/runtime and the recorded :rf.cofx map. An application fact
arrives under its own id only if the handler declares it. That map and the event
vector are the handler's inputs; it should not reach out to read the world.
| Inputs (coeffects) | Outputs (effects) | |
|---|---|---|
| Framework keys | :db, :event, :rf.frame/id, runtime context |
:db and commit-plane keys |
| Register more with | reg-cofx |
reg-fx |
| Use in a handler via | :rf.cofx/requires |
the :fx vector |
| The impure work happens in | the cofx supplier | the effect handler |
Delivery is declared-only: a fact the handler did not list in :rf.cofx/requires is
not delivered, even if the event carries it. So :rf.cofx/requires is the complete,
searchable list of what a handler reads from the world, and a test cannot quietly
supply a value that is nil in production.
Two grades: ambient and recordable¶
Every coeffect is registered with a grade that decides whether its value is recorded:
- Recordable (
:recordable? true). The value is recorded with the event and handed back unchanged on replay. Use it for any fact that can end up in app-db, like the saved todos or the clock. - Ambient (the default). The supplier runs again on replay and nothing is recorded. Use it only when no app-db write depends on the answer, such as a display preference or a diagnostic measurement.
Recorded facts travel in one flat map on the dispatch envelope, fact id to value:
Each child dispatch gets its own stamp, taken when that child is queued.
An ambient coeffect can take an argument, declared as [id arg], so one registration
serves several keys:
(rf/reg-cofx :ui/local-setting
{:doc "Ambient localStorage read for display settings."}
(fn [storage-key]
(some-> (.-localStorage js/globalThis) (.getItem storage-key))))
(rf/reg-event :todo.ui/apply-theme
{:rf.cofx/requires [[:ui/local-setting "theme"]]}
(fn [{:keys [ui/local-setting]} _]
;; styles the page only; nothing is written to app-db
{:fx [[:ui/set-theme-attr (or local-setting "system")]]})) ;; an app-registered fx
Ambient is right here because no app-db value depends on the answer: if replay reads a different theme, the state is unchanged.
A supplier must return its value synchronously, because coeffects are gathered before the handler runs. If the world can only answer asynchronously, as with a fetch, use an effect whose result comes back as a reply event (HTTP).
Keep credentials out of recordable coeffects. Tokens, nonces and key material must not be recorded as replayable facts. Read a saved credential in an effect and return it through a classified event, as Add authentication shows. Use fake credentials in tests.
For private facts that are appropriate to record, :sensitive paths on the
reg-cofx metadata redact their trace capture. Strict epoch replay then refuses
an input whose captured value was replaced by a marker; classification is not a
way to preserve secret replay inputs. Keep secrets out of traces
explains those boundaries.
Where a new id comes from¶
A new todo needs an id. An id ends up in app-db, so like the clock it can't come from
(random-uuid) inside the handler. In order of preference:
- Derive it from state. The todo handlers above compute the next id from the existing keys, so nothing new needs recording.
- Mint it at the dispatch site and put it in the event, as in
(dispatch [:todo/add (random-uuid) "Buy milk"]), with the handler destructuring[_ id title]. The id is part of the recorded event vector, so replay reproduces it. This is the usual choice when state can't supply one. - A recordable coeffect, only for values internal to event processing that the dispatch site shouldn't know about.
When the number of ids depends on state¶
Sometimes one event needs a fresh id for each of several items, and only state knows how many. Duplicating a list is the usual case: the copy needs one new id per row, and events still waiting in the queue can add, remove or replace rows before this one runs. If the ids only need to be unique within app-db, derive them from state as above. If they must be unique beyond it, because a server or another device sees them, record one fresh id and derive the rest from it:
(rf/reg-cofx :list/copy-id
{:doc "A fresh id for one list copy. The handler derives each row id from it."
:recordable? true
:schema :uuid}
(fn [] (random-uuid)))
(rf/reg-event :list/duplicate
{:rf.cofx/requires [:list/copy-id]}
(fn [{:keys [db list/copy-id]} [_ {:keys [list-id]}]]
(let [rows (get-in db [:lists list-id :rows])] ;; the rows as this event finds them
{:db (assoc-in db [:lists copy-id]
{:rows (into [] (map-indexed (fn [i row] (assoc row :id [copy-id i])))
rows)})})))
The handler counts the rows in the db it receives, which already reflects every
earlier event, so the copy matches the list at this event's place in the queue and
lands in a single step. One fact is recorded however many rows there are. Replay
supplies the same copy-id, so the handler derives the same row ids without
generating anything, and a test pins every id by supplying
{:rf.cofx {:list/copy-id #uuid "…"}}.
Record the id itself, not a seed for a random generator: a generator's output can
differ between hosts and versions, while [copy-id i] is plain data. If the row ids
must be UUIDs, derive them with a function that gives the same result on the JVM and
in JavaScript, which rules out hash.
The tempting alternatives each break something:
- Ids minted at the dispatch site are counted when the event is queued, so an earlier queued edit leaves too few or too many.
- Rows put in the event are copied as they were when it was queued, not as they are when it runs.
- A second event that does the copy runs behind every event already waiting.
- A fixed batch of ids, or one per entity, records facts this event never uses.
- Ids minted in an interceptor or the handler body are not on the recorded event, so replay mints different ones.
The ledger¶
App-db is the result of applying every event since the frame started, in order, like
the running total of a ledger. Two fresh frames fed the same events therefore finish
in the same state, provided handlers read nothing but :db, the event and recorded
facts. A handler that reads the clock or storage in its body uses a value the ledger
never recorded, and replay diverges. Declared requirements, recordable grades and the
rules for new ids all exist to prevent that.
This is what lets a bug report's list of events become a regression test that rebuilds the bad state in a fresh frame (Test a pipeline run). Xray's event rows show each epoch, including the recorded coeffects the event used.
Supplying facts in tests¶
The :rf.cofx dispatch option hands the runtime exact values. Supplied values win,
and the runtime fills in only what you leave out. In the live example above, change
the button's dispatch to:
Re-evaluate and every new todo is stamped with that instant. (The injected
dispatch takes an options map as its second argument and still targets the view's
frame.) Testing event handlers covers this, along with
:fx-overrides for stubbing effects.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
Declared fact is nil in the handler |
Destructured under the wrong key | Destructure it by its id beside db, e.g. {:keys [db rf/time-ms]} |
:rf.error/unregistered-cofx |
A :rf.cofx/requires entry names an unregistered id, usually a typo |
Fix the id, or register it with reg-cofx |
:rf.error/missing-required-cofx |
A :provided? fact was declared but nothing supplied it |
Supply it with the :rf.cofx dispatch option, or from its owning subsystem |
:rf.error/cofx-value-invalid |
A recordable value is not EDN (a function, an atom, a DOM node) | Record plain data, e.g. epoch milliseconds |
| Replay produces different state | The handler reads the clock, random-uuid, or storage in its body |
Declare the fact, or mint it at the dispatch site |
Advanced¶
Provided facts¶
A recordable coeffect can be registered with no supplier. A provided fact,
{:recordable? true :provided? true}, has its value put on the event by an owner:
a subsystem, or the dispatch call itself through the :rf.cofx option. Registering
it gives the fact a :doc, a :schema, and an id, so a typo'd requirement reports
differently from a missing value. :rf/time-ms is core's own provided fact, and the
add-on artefacts register more, such as routing's navigation token and SSR's
per-request fact.
Mint policies for generated facts¶
A recordable supplier like :todo.storage/todos generates its value when the event
starts processing. Whether generation is allowed is the mint policy:
:live(the default): the supplier runs and the value is recorded.:strict: nothing is generated, so a declared fact that wasn't supplied raises:rf.error/missing-required-cofx. Replay always uses it, and so does the:testframe preset.:explicit-live: a test opts back into generation.
Choose per dispatch with the :rf.cofx/mint-policy option, or per frame.
Testing event handlers uses the strict policy.
When a declaration goes wrong¶
Because every fact is declared, the runtime can tell a typo from a missing value.
Branch on the :rf.error/* id, never on the message.
- A required id that was never registered:
:rf.error/unregistered-cofx, before the declaring event is handled, not when its handler registers. - A declared
:provided?fact absent from the event::rf.error/missing-required-cofx, under every mint policy.:rf/time-msis always stamped, so it never fails this way. - A supplier that throws:
:rf.error/coeffect-exception, attributed to the supplier rather than the handler. - A recordable value that isn't EDN, such as a function, an atom or a DOM node:
:rf.error/cofx-value-invalidwith reason:non-edn-recordable-value, in production builds too. - The same id declared twice in one handler, or a coeffect named
:dbor:event::rf.error/cofx-name-collision. A malformed:rf.cofx/requires(not a vector, or a non-id entry) raises:rf.error/cofx-request-invalidat registration. - A contradictory grade, such as
:provided?without:recordable?, a missing supplier on a non-provided fact, or a provided fact given a supplier::rf.error/cofx-registration-invalid.