8. Actors¶
Every machine so far has been a singleton: one reg-machine id, one live
instance in the frame. [:rf/machine :auth.login/flow] is that instance, or
nil before the first event.
A spawned actor is another live instance of a machine type, created
at run time. It gets an allocated id (:auth/request#0). Use one when you
need many concurrent instances, or a child whose lifetime is bound to a
parent state.
The spec heading says "dynamic actors." That is an adjective for spawned instances, not a third kind. This guide says singleton and spawned.
Login stays the singleton. The HTTP request becomes a spawned child: it
starts when :submitting is entered and is destroyed on every exit.
State-bound spawn¶
Put :spawn on a state node. Entering the state creates the child. Leaving the
state — by any transition — destroys it.
The singleton login machine spawns a request actor on :submitting:
(rf/reg-machine :auth/request
{:initial :running
:data {}
:actions
{:issue-request
;; The tutorial's managed request, addressed to this actor's own id.
(fn [{data :data}]
{:fx [[:rf.http/managed
{:request {:method :post :url "/api/login"
:body (:credentials data)
:request-content-type :json}
:decode :json
:on-success [(:rf/self-id data) [:server-ok]]
:on-failure [(:rf/self-id data) [:server-err]]}]]})
:keep-token
(fn [{data :data [_ {:keys [value]}] :event}]
{:data (assoc data :token (:token value))})
:report-success
(fn [{data :data}]
{:fx [[:dispatch [(:rf/parent-id data) [:auth.login/success]]]]})}
:states
{:running
{:entry :issue-request
:on {:server-ok {:target :done
:action :keep-token}
:server-err :failed}}
:done {:entry :report-success :final? true :output-key :token}
:failed {:final? true :error? true}}})
:submitting
{:tags #{:auth/busy}
:spawn {:machine-id :auth/request
:data (fn [{:keys [event]}]
{:credentials (second event)})
:on-done (fn [{:keys [data result]}]
(assoc data :token result))
:on-error {:target :error-shown}}
:on {:auth.login/success :authed
:auth.login/cancel :idle}}
:auth.login/flow is still the singleton. :auth/request is the type.
Each visit to :submitting allocates a new spawned id. Leaving
:submitting — success, error, cancel, timeout — destroys that actor.
The two success halves do different jobs. :on-done folds the child's token
into the parent's :data and stops there; it is not a transition, so on its
own the parent would sit in :submitting wearing :auth/busy with the child
already gone. The move is an ordinary trigger: the child's final state
dispatches [:auth.login/success] to :rf/parent-id, and :submitting
handles it. Failure needs no counterpart — :on-error is a transition.
A larger shipped case binds one socket actor to a parent that spans several children:
;; cf. examples/patterns/websocket
(rf/reg-machine :ws/connection
{:initial :disconnected
:data {:url nil :auth-token nil}
:actions
{:record-options
(fn [{data :data [_ {:keys [url auth-token]}] :event}]
{:data (assoc data :url url :auth-token auth-token)})}
:states
{:disconnected
{:on {:ws/connect {:target :active
:action :record-options}}}
:active
{:spawn {:machine-id :websocket/socket
:data (fn [{snap :snapshot}]
{:url (-> snap :data :url)
:auth-token (-> snap :data :auth-token)})}
:on {:ws/closed :reconnecting
:ws/fatal :failed}
:initial :connecting
:states {:connecting {:on {:ws/opened :authenticating}}
:authenticating {:on {:ws/auth-ok :connected}}
:connected {}}}
:reconnecting {:on {:ws/connect :active}}
:failed {:on {:ws/connect :active}}}})
The socket is spawned on the :active parent, so one actor spans
:connecting → :authenticating → :connected. Cleanup is tied to the
statechart, not to hand-written cancel branches.
A state carries at most one :spawn. For several children, use a compound
state with one actor per substate, or :spawn-all.
Events are not forwarded to children; dispatch to the child id yourself. To
read a child's snapshot:
Spawn spec keys¶
Supply :machine-id or :definition, not both.
| Key | Meaning |
|---|---|
:machine-id |
registered machine type to spawn |
:definition |
inline machine definition instead of a registered id |
:data |
child's initial data — a map, or (fn [{:keys [snapshot event]}] …) evaluated on entry against the post-action snapshot |
:id-prefix |
base for the allocated id (:websocket/socket#0); defaults to :machine-id. Ids are counters, never gensym |
:system-id |
stable role name for lookup and messaging |
:start |
first event sent to the newborn |
:on-spawn |
advisory hook; its return is dropped (see Recording the spawned id) |
:on-done |
data-fold when the child reaches a successful final state |
:on-error |
transition when the child reaches an error final state or fails |
:timeout / :on-timeout |
wall-clock deadline on this child's lifetime; lowers onto the state's :after |
:fixed-actor-id |
explicit actor id for a per-state singleton |
The API reference lists the exact shapes.
Child runtime stamps¶
A declaratively spawned child gets three reserved keys in its :data:
| Key | Meaning |
|---|---|
:rf/self-id |
this actor's live id |
:rf/parent-id |
the parent actor's id (the address you dispatch to) |
:rf/invoke-id |
the path of the state that spawned it (e.g. [:active]) |
:actions
{:notify-open
(fn [{data :data}]
{:fx [[:dispatch [(:rf/parent-id data)
[:ws/opened {:source-socket-id (:rf/self-id data)}]]]]})}
A hand-emitted [:rf.machine/spawn …] stamps only :rf/self-id. There is no
structural parent, so pass a correspondent address through :data yourself.
Starting the child¶
The child always runs its initial :entry cascade first. Prefer putting startup
work there.
If you omit :start, the runtime also dispatches a synthetic
[:rf.machine.spawn/spawned]. Most children can ignore it. If you set
:start, that event is sent instead — never both.
Messaging¶
There is one messaging primitive: dispatch to the actor id.
A parent gets the id from :rf/spawned or from a :system-id.
A child gets the parent from :rf/parent-id. There is no separate send verb.
:system-id¶
Bind the actor to a role name when you want to address the role, not a generated instance id.
From an action (which cannot read app-db):
That fx is a no-op if the name is unbound. Outside an action, resolve the id and dispatch to it:
(when-let [actor (re-frame.machines/machine-by-system-id :primary-request)]
(rf/dispatch [actor [:request/cancel]]))
Recording the spawned id¶
:on-spawn is an observation hook. The runtime calls
(fn [{:keys [data id]}] …) and drops the return. Writing the id back
into :data records nothing, and a dev build emits
:rf.warning/on-spawn-return-ignored.
On every declarative :spawn / :spawn-all, the runtime already writes the
new id into the parent's :data under :rf/spawned, keyed by the
:spawn-bearing state's path:
;; cf. examples/patterns/websocket
:authenticating
{:entry (fn [{data :data}]
(let [socket (get-in data [:rf/spawned [:active]])]
{:fx [[:dispatch [socket [:send {:type :auth}]]]]}))}
The slot clears itself when the actor is destroyed. A later read returns nil,
not a dead id.
Use :system-id instead when you only need role-based messaging. The same id
is also at [:rf.runtime/machines :spawned <parent-id> <invoke-id>] for reads
outside a machine action.
When a child finishes¶
A one-shot child reports success by entering a root-level
:final? leaf and naming the :data slot to hand
up with :output-key. The parent folds that value in :on-done:
(rf/reg-machine :auth/request
{:initial :running
:data {}
:states
{:running {:on {:server-ok {:target :done
:action (fn [{data :data [_ token] :event}]
{:data (assoc data :token token)})}}}
:done {:final? true
:output-key :token}}})
:authenticating
{:spawn {:machine-id :auth/request
:on-done (fn [{:keys [data result]}]
(assoc data :token result))
:on-error :idle}
:on {:cancel :idle}}
:on-doneis a data-fold, not a transition:(fn [{:keys [data result]}] new-data). The parent's state does not move.:on-erroris a transition. A child that fails — a:final?leaf flagged:error? true, or a thrown action — routes the parent through that:on-shaped spec.- Entering a root-level
:final?destroys the child after the parent is notified. A nested:final?only tells the compound "this sub-flow is done"; see Hierarchical states.
Imperative spawn and destroy¶
Declarative :spawn lowers to reserved fx you can also emit from any :fx
vector:
{:fx [[:rf.machine/spawn
{:machine-id :logger
:system-id :logger
:data {:buffer []}
:start [:logger/connect]}]]}
{:fx [[:rf.machine/destroy actor-id]]}
Inside a machine state, prefer declarative :spawn. From an ordinary event
handler — when the number or timing of children is not one state node — emit
the fx.
An unregistered :machine-id (and no :definition) fails closed:
no snapshot, no id, no :start. The runtime raises
:rf.error/machine-spawn-unregistered-type.
Destroy is silently idempotent. Destroying an already-gone actor is a no-op.
Cancellation¶
A spawned actor is destroyed when:
- the parent exits the spawn-bearing state;
- a timeout or
:aftertransition exits that state; - a
:spawn-alljoin cancels surviving siblings; - you emit
[:rf.machine/destroy actor-id]; - the frame is torn down with
destroy-frame!. A view unmount or a route change does not destroy the frame — tear one down only when you mean to.
Destroy releases exactly three framework-managed kinds:
- in-flight
:rf.http/managedrequests this actor issued; - this actor's armed
:aftertimers; :rf.resource/*owners this actor holds.
The request is always aborted, but a reply is not always delivered. A reply
addressed to an ordinary event dispatches with :status :cancelled and an
:error of {:kind :rf.http/aborted :reason :actor-destroyed}. A reply
addressed back to the actor being destroyed — the [(:rf/self-id data) …]
shape :auth/request uses above — is never dispatched. The runtime classifies
it :status :stale and records a :rf.http/stale-suppressed trace row
carrying :rf.reply/stale-reason :rf.http/actor-destroyed-target-obsolete.
Anything else — a raw js/WebSocket, a setInterval, a Worker — you close
yourself in the child's :exit. That action runs on every destroy path:
The long-running-work example
cancels by leaving :working: that exit destroys every surviving child,
pending :after yield-timers included.
Timeouts¶
There is no :timeout-ms on :spawn or :spawn-all. Registration rejects
it with :rf.error/spawn-timeout-ms-removed.
:timeout / :on-timeout on the spawn spec is fine. It lowers onto the
spawn-bearing state's :after, so the deadline is anchored to that state's
entry and spans the child's internal retries:
:authenticating
{:spawn {:machine-id :auth/request
:timeout "PT30S"
:on-timeout {:target :auth-failed}}
:on {:cancel :idle
:auth-ok :authenticated}}
The same deadline as a state-level :after:
:authenticating
{:spawn {:machine-id :auth/request}
:after {30000 :auth-failed}
:on {:cancel :idle
:auth-ok :authenticated}}
One timer mechanism. When it fires, the state exits and the child is
destroyed. Durations are a positive integer (ms) or an ISO-8601 string
("PT30S"). A "5s" shorthand is rejected.
Fan-out and join with :spawn-all¶
Use :spawn-all when one state starts N children in parallel and
resumes on a join.
;; cf. examples/patterns/long_running_work
:working
{:spawn-all
{:children
[{:id :s1 :machine-id :work/processor :data {:shard :s1 :total 100}}
{:id :s2 :machine-id :work/processor :data {:shard :s2 :total 100}}
{:id :s3 :machine-id :work/processor :data {:shard :s3 :total 100}}]
:join :all
:on-child-done :work/child-done
:on-child-error :work/child-error
:on-all-complete [:work/all-done]
:on-any-failed [:work/any-failed]}
:on
{:progress {:action :record-progress} ;; no :target — don't respawn
:work/all-done {:target :complete}
:work/any-failed {:target :failed}
:cancel {:target :cancelled}}}
Each child is an ordinary machine. When it finishes, it dispatches the
parent's :on-child-done keyword, carrying its own :id:
:done {:entry (fn [{data :data}]
{:fx [[:dispatch [:work/flow [:work/child-done (:shard data)]]]]})}
The runtime owns the join bookkeeping. When the join resolves it fires the parent event and destroys any siblings still in flight.
Rules:
- Each child needs a unique
:id(the join key) on top of the usual spawn keys. Duplicates are:rf.error/machine-spawn-all-duplicate-id. :on-child-doneand:on-child-errorare required event keywords — missing either is:rf.error/machine-spawn-all-bad-shape.:joinis only:allor:any. There is no{:n n}and no predicate. Quorum ("N of M") is:after/:alwaysplus a:done-guardthat reads the join's done count — not a:joinmode.:on-all-completeis required for:all.:on-some-completeis required for:any. Missing either is:rf.error/machine-spawn-all-bad-shape.- An unregistered child type fails the whole invoke, atomically —
nothing is spawned, so an
:alljoin cannot hang on a child that never runs (:rf.error/machine-spawn-unregistered-type). - A wall-clock bound on the join is the same as single
:spawn::afteror:timeout/:on-timeouton the spawn-all-bearing state.
Independently valuable children — fire-and-forget, no cancel-the-rest — are
N separate :spawns, not a non-cancelling join.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
Parent :data never gets the child id; :rf.warning/on-spawn-return-ignored |
:on-spawn return is dropped |
Read (get-in data [:rf/spawned invoke-path]), or use :system-id |
No snapshot, no id; :rf.error/machine-spawn-unregistered-type |
:machine-id is not registered and there is no :definition |
Register the child type first |
Registration throws :rf.error/spawn-timeout-ms-removed |
:timeout-ms on :spawn / :spawn-all |
Use :timeout / :on-timeout, or :after on the parent state |
Registration throws :rf.error/machine-spawn-all-bad-shape on :join |
:join was {:n n}, a predicate, or another non-enum |
:join is only :all or :any. Quorum is :after / :always + :done-guard |
:join :all rejected |
missing :on-all-complete |
Give :on-all-complete an event vector |
:join :any rejected |
missing :on-some-complete |
Give :on-some-complete an event vector |
| Socket / interval / Worker still open after destroy | not a framework-managed resource | Close it in the child's :exit |
A self-addressed :on-failure never fires when the actor is destroyed |
the reply target names the actor being torn down, so it is obsolete | Expect no reply — it is suppressed as :status :stale. Clean up in the child's :exit, or address the reply to an event outside the actor |
| Children torn down (or respawned) on a progress event | the parent's :on had a :target |
Omit :target so the transition is internal |