The model: six dominoes, one loop¶
This page is the whole mental model. Every other page in this guide — every concept page, every how-to, every tutorial step — is a zoom-in on one piece of it. So it's worth reading once now, slowly, and coming back whenever something downstream starts to feel mysterious.
If you know Redux, you already have the skeleton: one store, one-way data flow, dispatch → reducer → store → selector → render. re-frame2 keeps that loop and changes two things. First, side effects are data the handler returns, not middleware you bolt on. Second, the loop runs to completion before anything re-renders. And if you don't know Redux, don't worry — the loop is small enough to hold in your head, which is rather the whole point.
If you quote one sentence from this guide, quote this one:
State is in one place; views are the last thing that happens, not the first.
State in one place, views last¶
State lives in exactly one place: app-db, your app's single immutable state map. Something happens — a click, a server reply, a timer fires. Each of those becomes an event, a small vector of data that simply describes what happened. An event handler — a pure function — takes the current state and that event and computes what should change. Subscriptions are derivations over app-db: they recompute the slices of state that views care about. And then, last of all, views (render functions that turn subscription values into UI) re-render to match.
Notice what's absent here, because this is the part that trips people coming from other frameworks. Views don't own state. They don't fetch. They don't decide anything. A view is a render function over subscription values, and that is its entire job. In a typical frontend, the most bug-prone real estate is the place where state, effects, and rendering tangle together — and here that place simply doesn't exist. Views render, and nothing more. (If you want the longer argument for why this inversion earns its ceremony, it's Inside out: why views come last.)
The six dominoes¶
Every event walks the same six-step pipeline, in order, every single time. People draw it as a row of dominoes because that's genuinely what it is: knock the first one over and the cascade runs to the end, deterministically.
flowchart LR
D1["1 · Event<br/>dispatched"] --> D2["2 · Handler<br/>runs (pure)"]
D2 --> D3["3 · Effects<br/>produced (data)"]
D3 --> D4["4 · Effects<br/>executed (runtime)"]
D4 --> D5["5 · Subscriptions<br/>recompute"]
D5 --> D6["6 · Views<br/>re-render"]
- Event dispatched.
(rf/dispatch [:counter/inc])— dispatch puts the event vector on the runtime's queue and returns immediately. Nothing has run yet, and the click handler's job is already over. - Handler runs. The runtime pops the event off the queue and runs its registered handler, a pure function: same inputs, same output, no I/O.
- Effects produced. The handler returns a description of everything that should happen, as data —
{:db <new-state> :fx [[effect-id args] ...]}— and performs none of it itself. (An effect is just one of those data entries: a request for the world to do something.) - Effects executed. The runtime walks that description and actually does the work. The app-db swap happens inside this domino:
:dbis itself an effect, applied as one atomic swap, so no half-updated state is ever visible. Then any other effects fire — the HTTP request, the navigation, the storage write. - Subscriptions recompute. app-db changed, so the derivations watching the changed parts re-run. If a subscription's value comes out the same, propagation stops right there, and nothing downstream of it re-renders.
- Views re-render. Views that deref a changed subscription re-run, and the DOM is patched to match.
One pass through the pipeline is one epoch. Dominoes and epoch are the same picture under two names — you'll hear both, so it's worth knowing they point at the same thing.
In code, every handler is registered with reg-event, and it returns a map: the next state under :db, plus anything else to do. The simplest handler only touches state, so the map has only :db:
When the event also needs the world to do something, you add an :fx vector to the same map — same registration, same signature, still pure, still just data:
(rf/reg-event :feed/refresh
(fn [{:keys [db]} _event]
{:db (assoc db :feed/loading? true)
:fx [[:rf.http/managed
{:request {:method :get
:url "/api/articles"}
:on-success [:feed/loaded]
:on-failure [:feed/load-failed]}]]}))
One form, one map: a db update is the effect {:db …}, and everything else rides in :fx beside it. The server's reply comes back as a new event — [:feed/loaded ...] — which walks the same six dominoes itself. And the world coming in is symmetric. A handler that needs a fact from the world — the current time, a stored token — declares it with :rf.cofx/requires and receives it as an input (that incoming fact is a coeffect), rather than reaching out for it mid-function. Both directions live in Effects and coeffects.
Coming from Redux? Dominoes 3–4 replace the entire middleware question — thunks, sagas, observables — with a plain map the reducer-equivalent returns.
Coming from re-frame v1? Same six dominoes; the deltas (effects grammar, coeffects, frames) are catalogued in From re-frame v1.
A small virtual machine¶
Structurally, a re-frame2 app is a small virtual machine. The handlers you register are its instruction set — and the selected set a given machine loads is its image. The events you dispatch are instructions. The stream of events the app sees over its lifetime is the program, and app-db is the machine's memory. Growing the app means registering more instructions, which means the machine itself never gets more complicated. So the cost of adding a feature is bounded by the size of the feature, not the size of the app — there's simply nowhere else for the relevant logic to hide. (Most apps load one implicit image and never name it; Images is for the day you want two machines on one page running different instruction sets.)
When the loop is overkill
If your whole app really is a counter, all of this is ceremony and useState is six lines. The loop pays for itself once the app is bigger than the loop — which, for anything you'll actually ship, it will be.
Run-to-completion¶
The runtime drains the entire event queue before subscriptions recompute and views re-render. It's one scheduling rule, and it quietly does a lot of work for you. If a handler's effects dispatch three follow-up events, the screen does not flicker through each intermediate state; subscriptions and views see state once, after the whole batch has settled. The user sees coherent states rather than transitions — the form is submitting or it has failed, never both for a single paint. You give up a little scheduling flexibility, and in return fast interactions can't catch your UI mid-thought.
One impure spot, one wire¶
Domino 4 is the only place the system touches the world, and everything that crosses it was first written down as data. That discipline isn't aesthetics — it's what makes the loop observable. Every event, every effect, and every state change passes one known point in one known shape, which means a single trace wire can watch the whole app go by. Every dev tool then reads that same wire: the Xray inspector, time-travel, scenario replay, an AI pair attached to your running app. You gave up "anything can change anything from anywhere," and inspectability is what you bought with it. (Observability: one wire, every tool is the tour, and the trade is stated as a framework principle in Principles.)
Where the loop runs¶
All of this — the queue, app-db, the subscription cache — lives inside a frame: an isolated world the loop runs in. Most apps have exactly one frame and never name it, so dispatch and subscribe just work. But the frame is why a page can mount the same app several times without the copies sharing state, why every test gets a pristine world, and why a server can run one frame per request. Frames: isolated worlds has the shape.
The map¶
The rest of the concepts shelf zooms into the loop, one piece per page:
| The piece | Page |
|---|---|
| Events, the queue, and the cascade (dominoes 1–2) | Events and the cascade |
| The one place state lives | app-db: the one place |
| The world at the boundary (dominoes 3–4) | Effects and coeffects |
| The derivation graph (domino 5) | Subscriptions |
| Pure functions of data (domino 6) | Views |
| The isolated world the loop runs in | Frames |
And everything else is built on the loop, never beside it:
| You want | Page |
|---|---|
| Derived values your handlers can read | Flows |
| Modes and transitions made explicit | State machines |
| The managed HTTP request | HTTP |
| Server data, cached and invalidated | Server state: resources |
| The URL as just another input | Routing: the URL is a sub |
| Rendering on the server | Server-side rendering |
| Cross-cutting behaviour around handlers | Interceptors |
| Which registrations a frame runs | Images |
| Failures as structured data | Errors: dossiers, not log lines |
| Watching the loop run | Observability: one wire, every tool |
Not sure whether a value belongs in app-db, a sub, a flow, a resource, or a machine? Where should this value live? is the decision guide.
You can now:
- name the six dominoes in order — dispatched, handler runs, effects produced, effects executed, subscriptions, views — and say which one touches the world (the fourth, app-db swap included)
- explain why a view can never be the source of a state bug: it's downstream of everything and decides nothing
- open any concepts page knowing exactly where its piece plugs into the loop