6. Parallel regions¶
The session machine so far has one active leaf. A login page often has two independent axes at once:
- the form: editing, valid, or invalid;
- the submit flow: idle, submitting, authed, or error.
Those axes are orthogonal. A flat machine would have to name the
cross-product (:idle-and-invalid, :submitting-and-valid, …). Parallel
regions keep the axes separate.
The Nine States example is the same idea with three axes. This page teaches it on the login page first.
When to use parallel regions¶
Use parallel regions when:
- the axes belong to one conceptual feature;
- they share one
:datamap; - more than one axis can transition in response to the same event;
- the cross-product would be awkward to name.
Do not use parallel regions when the axes are separate features that do not share data. Register separate machines instead.
There is no per-region :data. A parallel machine has one shared :data map.
The shape¶
A parallel machine has :type :parallel and :regions at the root.
(rf/defmachine login-page
{:type :parallel
:data {:attempts 0 :error nil}
:guards
{:form-valid?
(fn [{:keys [tags]}]
(contains? tags :form/valid))}
:regions
{:form
{:initial :editing
:states
{:editing {:tags #{:form/editing}
:on {:form/valid :valid
:form/invalid :invalid}}
:valid {:tags #{:form/valid}
:on {:form/invalid :invalid}}
:invalid {:tags #{:form/invalid}
:on {:form/valid :valid}}}}
:auth
{:initial :idle
:states
{:idle
{:on {:auth.login/submit {:target :submitting
:guard :form-valid?}}}
:submitting
{:tags #{:auth/busy}
:on {:auth.login/success :authed
:auth.login/failure :error-shown}}
:authed {:tags #{:auth/authed}}
:error-shown {:on {:auth.login/dismiss :idle}}}}}}})
(rf/reg-machine :auth.login/flow login-page)
Still one singleton. The form region does not know the auth region's
state names. Submit reads :form/valid off the tag union.
The Nine States example is the same
shape with a third :mode region.
Each region body looks like a small machine: it has :initial and :states.
At the top level, a parallel machine does not also declare root :initial and
root :states. Registration throws :rf.error/machine-parallel-bad-shape if
the root has both, or if a region is missing its own :initial.
The snapshot state is a region map¶
@(rf/subscribe [:rf/machine :auth.login/flow])
;; => {:state {:form :valid
;; :auth :submitting}
;; :data {:attempts 0 :error nil}
;; :tags #{:form/valid :auth/busy}}
A compound region contributes a path as its region value:
Every region starts¶
At machine birth, every region enters its own initial state. If a region is
hierarchical, it follows its own :initial cascade to a leaf.
Every region's entry actions run first. Then the parent settles :always across
the whole configuration — the same freeze / select / apply rounds used after an
event. See :always stabilization.
Event broadcast¶
Every event dispatched at a parallel machine is broadcast to every region.
For each region:
- if the active state has a matching transition whose guard passes, that region transitions;
- otherwise that region stays where it is.
(rf/dispatch-sync [:auth.login/flow [:form/valid]])
;; only the :form region handles it
;; {:form :valid, :auth :idle}
If several regions handle the same event, their actions run in region
declaration order and write to the shared :data in that order.
Select first, then apply¶
Transition selection is done against the frozen pre-event configuration. Then the selected transitions are applied.
That means region order can affect action/data accumulation order, but it does not affect which transitions are selected.
A region guard cannot see a sibling region's move from the same broadcast event.
It sees the sibling state as that broadcast's selection froze it. One macrostep
can run several selections — the broadcast, then parent :always rounds, then
any :raise re-broadcast — and each one freezes the view afresh.
Use :raise if one region's move should trigger a second broadcast inside the
same macrostep.
Shared data¶
Because all regions share :data, two regions handling one event can both
update it. :data merges, so an action that returns {:data {:count n}} writes
only :count.
If two regions run :bump, :count increments twice.
If that is not what you meant, put the update in one region, or model the coordination as a root transition.
Root :on fallback¶
A parallel machine may declare root :on alongside :regions.
(rf/reg-machine :board
{:type :parallel
:data {}
:regions
{:left {:initial :one :states {:one {} :two {}}}
:right {:initial :one :states {:one {} :two {}}}}
:on
{:go-all {:target [[:left :two] [:right :two]]}}})
The root transition fires only when no region handles the event.
If any region handles the event, the root fallback is suppressed entirely. It is not applied to only the regions that did not handle it.
Root targets are region-qualified:
A bare keyword target at the root of a parallel machine is rejected at
registration with :rf.error/machine-parallel-root-on-bad-target.
A parallel root may also declare its own :after — the timer-driven analog
of the root :on. It arms at machine birth and belongs to the root, so no
region's own transitions cancel or restart it.
When every region finishes¶
When every region reaches a :final? leaf, the root's :on-done fires. A
parallel root's :on-done runs its :action and emits its :fx only; a
:target is rejected at registration
(:rf.error/machine-parallel-on-done-target) because the root has no
sibling state to land on. The machine stays in the all-final configuration.
Without a root :on-done, all-regions-final ends the machine the way a
root-level :final? leaf does: a singleton is destroyed, and a spawned
child reports to its parent
(Actors).
Coordinating regions: tags as stateIn¶
A region guard or action gets two extra context keys. They appear only inside a
parallel region; a flat machine's context stays {:data :event :state :meta}.
| Key | Meaning |
|---|---|
:tags |
the machine-wide tag union from the pre-event snapshot |
:all-state |
the full region → active-state map from the pre-event snapshot |
Prefer tags:
Use :all-state only when exact state names are the contract:
Both keys are frozen for the selection round that is currently choosing transitions, not for the whole macrostep. Between rounds the view is re-frozen, so each round sees the completed result of the one before it. A same-event move in a sibling region becomes visible on the next round, not on this one.
:always, :after, and :spawn are region-scoped¶
A region chooses where its :always targets — those targets stay inside that
region. The parent owns settle; see
below.
A region state's :after timer belongs to that region state. Sibling
transitions do not cancel it.
A region state's :spawn child is bound to that region state. Sibling
transitions do not destroy it.
The exception is :raise: a raised event is broadcast to every region, just
like an external event, but still inside the current macrostep.
:always stabilization is parent-owned¶
A region chooses where its :always targets. The parent owns settle.
After the event set has applied, the parent freezes the whole configuration,
selects every enabled regional :always against that one frozen view, applies
the selected set, and freezes again. It repeats until a round selects nothing.
The loop is not "each region settles itself." One region's :always action can
enable a sibling's :always, and that sibling waits for the next parent
round. A tiny case: :source writes :ready?, :gate's :always reads it and
writes :cleared?, :audit's :always reads :cleared? — one event, two
parent rounds, then the snapshot commits.
The loop is bounded by :always-depth-limit (default 16). The limit counts
parent rounds — a round in which five regions move is one round, not five.
Tags compose across regions¶
The snapshot's :tags is the union of every active state in every region.
That lets a view ask one question without knowing which region owns the tag:
It also lets you build a single render-priority table across all axes. See Tags.
Limitations¶
Nested parallel regions are not supported. A region may be hierarchical, but it
may not itself declare :type :parallel. Registration throws
:rf.error/machine-parallel-nested-not-supported.
If you find yourself wanting nested parallel, flatten the axes into one parallel root or split the feature into several machines.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
Registration throws :rf.error/machine-parallel-bad-shape |
Root also has :initial / :states, or a region lacks :initial |
:type :parallel uses :regions only; every region declares :initial |
Registration throws :rf.error/machine-parallel-root-on-bad-target |
Root :on used a bare keyword target |
Region-qualify: [:left :two] or [[:left :two] [:right :two]] |
Registration throws :rf.error/machine-parallel-nested-not-supported |
A region itself declares :type :parallel |
Flatten the axes, or split into separate machines |
Shared :data incremented twice on one event |
Two regions handled the same event and both wrote | Put the write in one region, or on a root :on |
| Guard cannot see a sibling's same-event move | Selection is frozen for the round | Use :raise, or a later :always round |