Skip to content

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:

@(rf/subscribe [:rf/machine actor-id])

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.

:spawn {:machine-id :worker
        :start      [:worker/start {:shard :a}]}

Messaging

There is one messaging primitive: dispatch to the actor id.

{:fx [[:dispatch [child-id [:worker/cancel]]]]}

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.

:spawn {:machine-id     :request/protocol
        :fixed-actor-id :primary-request
        :data           {:url "/api/user"}}

From an action (which cannot read app-db) — the same :dispatch as anywhere else:

{:fx [[:dispatch [:primary-request [:request/cancel]]]]}

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-done is 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-done and let an :always guard 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-done is applied on the parent's next macrostep, not inside the child's teardown cascade.

  • :on-error is 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 :after transition exits that state;
  • a :spawn-all join 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/managed requests this actor issued;
  • this actor's armed :after timers;
  • :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:

:connected
{:entry :open-socket
 :exit  :close-socket
 :on    {:disconnect :idle}}

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 :data fold 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.
  • :join is only :all or :any. There is no {:n n} and no predicate. Quorum ("N of M") is :after / :always plus a :done-guard that reads the join's done count — not a :join mode.
  • :on-all-complete is required for :all. :on-some-complete is 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 :all join 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: :after or :timeout / :on-timeout on 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