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 |
:start |
first event sent to the newborn |
: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 — when it chose the address —
from its own :data. A child gets the parent from :rf/parent-id. There is no
separate send verb.
:fixed-actor-id¶
Give the actor a well-known address when you want a stable name rather than an allocated instance id. The address IS the id: nothing else has to be bound, and nothing else has to be looked up.
From an action (which cannot read app-db) — the same :dispatch as anywhere
else:
A re-entered :fixed-actor-id child is a NEW INCARNATION at the SAME address.
An ordinary dispatch to that address reaches whichever incarnation currently
owns it; the join and lifecycle machinery tells incarnations apart internally.
Recording the spawned id¶
On every declarative :spawn / :spawn-all, the runtime 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 :fixed-actor-id instead when you want to choose the address yourself.
The allocated 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 fold itself does not move the parent — but the completion event then flows into the parent's ordinary macrostep, so the parent can advance on it. Fold the result in:on-doneand let an:alwaysguard read it::configuring {:spawn {:machine-id :app/loader :on-done (fn [{:keys [data result]}] (assoc data :config result))} :always [{:guard :config-loaded? :target :loading-deps}]}An explicit
:on {:rf.machine.spawn/done {:target :loading-deps}}works too. This is what a child would once have needed a hand-rolled dispatch back to its parent for.:on-doneis applied on the parent's next macrostep, not inside the child's teardown cascade. -
: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
:fixed-actor-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}
:on-done (fn [{:keys [data result]}] (assoc-in data [:results :s1] result))}
{: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-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, and it carries no parent vocabulary at
all. It completes exactly the way it would under a single :spawn — by
entering a root-level :final? leaf, naming its result slot with
:output-key:
:done {:final? true :output-key :shard-result} ;; success
:failed {:final? true :error? true :output-key :reason} ;; failure
So one child machine composes unchanged under :spawn and under
:spawn-all. It needs no action that dispatches to its parent, and
:meta {:terminal? true} on such a leaf is redundant with :final?.
The runtime owns the join bookkeeping. When the join resolves it fires the
parent event and destroys any siblings still in flight. The event carries
the decisive child and its result:
[<parent-id> [<resolution-event…> <decisive-child-id> <result>]] — one
value, the decisive child's :output-key slot (its error payload on
:on-any-failed).
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. - There are no child-vocabulary keys. The block declares only how results
combine:
:children,:join,:on-all-complete,:on-some-complete,:on-any-failed. Any other bare key — including the retired keys that once named the events children dispatched — is:rf.error/machine-spawn-all-bad-shape. - A child spec may declare
:on-done— a:datafold on the parent at that child's finality, run before the join fold. It may not declare:on-error(:rf.error/machine-unknown-spawn-key): failure control flow under a join is the block's:on-any-failed, which decides for the whole fan-out. :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 |
the spawn was hand-emitted from an action's :fx, so it carries no declarative invoke-id to key :rf/spawned under |
Choose an explicit :fixed-actor-id, store it in :data, or use a declarative :spawn |
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 |
Registration throws :rf.error/machine-spawn-all-bad-shape naming a child-event key |
a retired child-vocabulary key on the :spawn-all block |
Delete it. The child completes by reaching a :final? leaf; read the result off the resolution event or a child :on-done |
Registration throws :rf.error/machine-unknown-spawn-key on a :spawn-all child |
the child spec declared :on-error |
Route failure through the block's :on-any-failed — a join has no per-child error transition |
: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 |