Actors: spawning child machines¶
A machine doesn't have to be a singleton. When a state needs to run some self-contained, asynchronous activity — an HTTP request, a websocket session, a worker grinding through a shard — you can spawn a child machine to do it, scoped to that state's lifetime. The child is a full machine instance with its own :state and :data; we call it an actor.
The pattern earns its keep when an activity has a lifetime that should match a state's lifetime: start it when you enter the state, and — this is the part that's easy to get wrong by hand — tear it down on every way out of the state, including the ones you forgot about. Declare the binding once and the runtime enforces it.
A spawned child can be state-bound — alive for exactly as long as a state is active — or dynamically created; re-frame2 spells both as :spawn. There is no actor object: a spawned actor's liveness is its snapshot — a plain value at [:rf.runtime/machines :snapshots <actor-id>] in the frame's runtime-db. You address it by dispatch-ing to its id, exactly like any other handler, and it reverts with the frame on time-travel.
This page assumes you've met the transition table and guards and actions from Concepts, plus :after timers — they carry the timeout story below.
Your first spawned actor — declarative :spawn¶
Put a :spawn map on a state node. While the machine sits in that state, a child actor of the named machine exists; on any transition out of the state, it's destroyed.
Here's the heart of the websocket example: a connection machine whose :active state owns a live socket. The socket is its own machine (:websocket/socket), spawned on the :active parent so one socket spans the :connecting → :authenticating → :connected leaves nested inside it.
(rf/reg-machine :ws/connection
{:initial :disconnected
:data {:url nil :auth-token nil}
:states
{:disconnected
{:on {:ws/connect {:target :active :action :record-opts}}}
:active
{:spawn {:machine-id :websocket/socket
;; :data can be a fn — evaluated on entry against the
;; post-action snapshot, so it reads whatever's current.
:data (fn [{snap :snapshot}]
{:url (-> snap :data :url)
:auth-token (-> snap :data :auth-token)})}
:on {:ws/closed {:target :reconnecting}
:ws/fatal {:target :failed}}
;; ... compound :initial / nested leaves omitted ...
}}})
Enter :active → the runtime spawns a :websocket/socket actor. Leave :active by any door — :ws/closed, :ws/fatal, a frame teardown — and the runtime destroys it. The parent never wrote "spawn" or "destroy"; :spawn is registration-time sugar that rewrites the state's :entry/:exit into the imperative spawn/destroy effects you'll meet below. From the outside it's indistinguishable from a machine that wired those slots by hand.
The spawn-spec keys¶
The map under :spawn accepts:
| key | what it does |
|---|---|
:machine-id or :definition |
which machine to spawn — a registered machine id, or an inline transition table. Supply exactly one (both, or neither, is a registration error). |
:data |
initial :data for the child — a literal map, or (fn [{:keys [snapshot event]}] data) evaluated at entry against the post-action snapshot (the transition's own :action has already run, so its writes are visible). |
:id-prefix |
base for the gensym'd actor id (:websocket/socket#0); defaults to :machine-id. |
:start |
an event vector dispatched to the newborn as its first event (see the synthetic kick-off below). |
:system-id |
bind the actor to a per-frame name so other code can address it without knowing the gensym'd id (see Messaging). |
:on-spawn |
(fn [{:keys [data id]}] _) — an advisory observation hook; its return is dropped (see Recording the id). |
:on-done |
(fn [{:keys [data result]}] new-data) — success notification when the child reaches a non-error :final? leaf. |
:on-error |
an :on-shaped transition — fires when the child fails (an :error? final leaf, or a thrown action). The parent changes state. |
:fixed-actor-id |
an explicit actor id instead of a gensym, for a per-state singleton actor. |
:on-done / :on-error are the completion story — they pair with the child declaring a :final? leaf, and they get their own section below. The API reference lists the exact shapes.
One :spawn per state
A state node carries at most one :spawn — for multiple children, use a compound state with one actor per substate, or :spawn-all for parallelism. Events aren't auto-forwarded to children — forward them explicitly with :fx [[:dispatch [child-id ev]]]. To observe a child's snapshot, read it with the [:rf/machine actor-id] subscription.
Runtime stamps: how a child knows who it is¶
When the runtime builds a declaratively-spawned child's initial snapshot, it stamps three framework-reserved keys into the child's :data — so the child can address itself and its parent without the parent having to thread that information through:
| key | value |
|---|---|
:rf/self-id |
the actor's own address (e.g. :websocket/socket#0) |
:rf/parent-id |
the parent machine's registration id |
:rf/invoke-id |
the absolute path of the :spawn-bearing state node |
The child reads these as ordinary :data lookups inside its actions. Here's the socket actor reporting back to its parent on open (simplified from the websocket example):
(rf/reg-machine :websocket/socket
{:initial :opening
:data {:url nil :auth-token nil}
:actions
{:open-socket
(fn [{data :data}]
(let [self-id (:rf/self-id data) ;; my own address
parent-id (:rf/parent-id data) ;; who spawned me
socket (open-host-socket! self-id (:url data))]
(store-socket! self-id socket)
;; Report up to the parent, tagging the message with my socket id.
{:fx [[:dispatch [parent-id [:ws/opened {:source-socket-id self-id}]]]]}))}
:states
{:opening {:entry :open-socket
:always [{:target :open}]}
:open {:on {:send {:action :send-via-socket}}}}})
Addressing the parent
A child can address its parent without being told who it is — the runtime stamps :rf/parent-id and the child reads it. Caveat: the stamp is written only on the declarative :spawn / :spawn-all path. A hand-emitted [:rf.machine/spawn …] (next section) records only :rf/self-id — there's no structural parent — so a hand-spawned child must be handed a correspondent address through its :data.
The synthetic spawned event, and :start¶
A freshly-spawned actor needs a first nudge. Two ways:
- No
:start(the common case). The runtime dispatches a synthetic[:rf.machine.spawn/spawned]into the newborn. The child's initial-state:entrycascade fires first regardless, so the canonical shape is to do the actor's first work in:entry(as:open-socketdoes above). A generic child can also listen for the kick-off explicitly:
;; A child springing into action the instant it's spawned (no :start needed).
;; The runtime conjures [:rf.machine.spawn/spawned] when there's nothing to :start.
:idle {:on {:rf.machine.spawn/spawned :processing}}
Both work; new code prefers :entry (it fires for every kick-off mode). An unhandled :rf.machine.spawn/spawned is a benign no-op — it's reserved framework lifecycle traffic, exempt from the unhandled-event trace.
- With
:start [:begin url]. Hand the child a specific first event payload. The two paths are mutually exclusive — an actor gets the synthetic kick-off or your:start, never both.
Recording the spawned id¶
You will reach for :on-spawn to "capture the child's id into :data" — and it won't work. :on-spawn is an advisory observation hook: the runtime calls it with {:data <parent-data> :id <new-id>} and drops its return value. Writing (assoc data :pending id) records nothing (and emits a :rf.warning/on-spawn-return-ignored in dev). The runtime already tracks the id for you — so there's no self-dispatch dance to write, and no side-channel atom either.
There are two first-class mechanisms; reach for whichever fits.
1. The :rf/spawned :data slot (read the id in-snapshot)¶
On every declarative :spawn / :spawn-all, the pure transition reducer binds the new actor's id into the spawning machine's own :data under the reserved per-invoke map :rf/spawned — {:rf/spawned {<invoke-id> <actor-id>}}, keyed by the absolute path of the :spawn-bearing state. A later action reads it straight off its own :data:
:active
{:spawn {:machine-id :websocket/socket
:data (fn [{snap :snapshot}] {:url (-> snap :data :url)})}
;; ... nested leaves; a leaf action reads the spawned socket's id:
:states
{:authenticating
{:entry (fn [{data :data}]
;; the socket actor's id — no :on-spawn, no atom
(let [socket (get-in data [:rf/spawned [:active]])]
{:fx [[:dispatch [socket [:send {:type :auth}]]]]}))}}}
This is re-frame2's spelling of XState v5's spawn(...)-into-context capture, except the id rides the revertible, SSR-survivable snapshot rather than a live object reference. It's keyed by <invoke-id> (the same path the child records under :rf/invoke-id), so multiple :spawn-bearing states don't collide.
The slot clears itself on teardown. When the actor is destroyed — leaving the state by any door, a completing :final?, a frame teardown — the runtime dissocs [:rf/spawned <invoke-id>] from the parent's :data, so it mirrors the runtime registry exactly. A read after the child is gone returns nil, never a dead id. That's what makes the websocket example treat the slot as its connection clock: the live socket's id is the epoch, and it goes nil the instant the socket dies — no :exit action nulling anything.
2. :system-id (address the actor by a stable name)¶
Give the spawn a :system-id and message it by that role name instead of the gensym'd id — best when you want a stable correspondent rather than to hold the id yourself. See Addressing by name with :system-id below.
The runtime registry, for outside-the-machine reads
The id is also always at [:rf.runtime/machines :spawned <parent-id> <invoke-id>] in the frame's runtime-db — the same value the :rf/spawned :data slot mirrors. Read it there when you're outside the machine's own action context (mechanism 1 needs the action's :data); inside an action, prefer the :rf/spawned slot.
Cross-machine messaging¶
There is one mechanism: dispatch by id. A machine is an event handler — you message it the way you message anything, by dispatching to the id you hold. A spawned actor's id is a handler address, so a parent messages a child — and a child messages its parent — the same way:
;; parent → child, or child → parent: same primitive.
{:fx [[:dispatch [some-machine-id [:some-event payload]]]]}
You get the id where the actor was created and carry it as an ordinary value: read the parent's :rf/spawned :data slot, observe it in :on-spawn, or read it from the snapshot — then dispatch to it. There is no machine-specific send verb; there is only dispatch.
Addressing by name with :system-id¶
When you don't want to thread the gensym'd id around, name the actor at spawn with :system-id, then message it by that name. The canonical action-side surface is the reserved :rf.machine/dispatch-to-system fx (a machine action can't read app-db, so it can't look the id up itself):
;; spawn under a role-name:
:spawn {:machine-id :request/protocol
:system-id :primary-request
:data {:url "/api/foo"}}
;; later, from any action's :fx — message it by name (no-op if unbound):
{:fx [[:rf.machine/dispatch-to-system [:primary-request [:cancel]]]]}
From a non-action call site you can resolve the id directly with (re-frame.machines/machine-by-system-id :primary-request) and dispatch to it. The binding lives in the frame's runtime-db, so it reverts with everything else.
:rf.machine/dispatch-to-system is a parked parity escape
This fx has zero in-repo consumers as of 2026-07-10 — the everyday send is plain dispatch to the id you hold (above). It is retained as the one named-addressing escape for XState v6 actor-system parity (systemId addressing — behavioural parity, not API-mimicry); the facade audit at API-freeze rules on whether it ships. Reach for it only when you genuinely need role-name addressing from inside an action (which can't read app-db to resolve the id itself). There is no separate machine send verb.
Including a reply address¶
There's no sender/sendTo-reply special form. Put the reply event in the request (the standard re-frame2 reply convention): address the request by name, but carry the reply id in the payload, so the reply lands in a specific actor rather than whoever currently owns the name.
When a child finishes¶
A spawned child that genuinely completes — a one-shot protocol, a finished handshake — declares a :final? leaf. Entering it terminates the child: the runtime reads the child's result, notifies the parent, then destroys the actor. The child names its result with :output-key — the slot of its final :data to hand up — and the parent's :spawn declares :on-done, which receives that value as result:
;; Child — a one-shot auth handshake that reports its token, then ends.
(rf/reg-machine :auth-flow
{:initial :running
:data {}
:states
{:running {:on {:server-ok {:target :done
:action (fn [{data :data ev :event}]
{:data (assoc data :token (second ev))})}}}
:done {:final? true
:output-key :token}}}) ;; report :data's :token back to the parent
;; Parent — :on-done folds the child's result into its own :data.
:authenticating
{:spawn {:machine-id :auth-flow
:on-done (fn [{data :data result :result}] ;; result = the child's :token
(assoc data :token result))
:on-error :idle} ;; child failed → a transition
:on {:auth/cancelled :idle}}
When :auth-flow enters :done, the runtime reads its :token, hands it to the parent's :on-done as result, then tears the child down — no stale id left behind. The details worth internalising:
:on-doneis a data-fold, not a transition. It's(fn [{:keys [data result]}] new-data)— it returns the parent's next:data; the parent's state doesn't move.:on-errorIS a transition. A child that fails — it reaches a:final?leaf flagged:error? true, or one of its actions throws — routes the parent through the:on-shaped:on-errorspec. Failure is control flow, not just observability.- Completion is event-shaped. The
:output-keyvalue flows to:on-doneat the moment of completion, then the child is gone — there is no long-lived output slot to read later. Want a computed output? Write it into the final state's:datawith a transition:action; there's no:output-fn. - Final means final — even for a singleton. A root-level
:final?leaf auto-destroys any machine, spawned or not. A state a machine rests in indefinitely (an:authedend-screen) is an ordinary leaf with:final?omitted. - Nested finals are different. A
:final?leaf inside a compound state doesn't end the machine — it signals "this sub-flow is done" to the enclosing compound's own:on-done, and the machine keeps running. That's the hierarchical pattern; see Hierarchical states → When a sub-flow finishes.
Imperative spawn and destroy¶
:spawn desugars to two reserved effects you can also emit by hand from any :fx vector. They are fx-ids inside :fx, not top-level effect keys:
;; Spawn from an event handler (e.g. a boot-time bootstrap event):
(rf/reg-event :session/start-logger
(fn [_ _]
{:fx [[:rf.machine/spawn {:machine-id :machines/log-shipper
:id-prefix :logger
:system-id :logger ;; address it later by name
:data {:buffer []}
:start [:logger/connect]}]]}))
;; Tear it down later:
(rf/reg-event :session/stop-logger
(fn [_ _]
{:fx [[:rf.machine/dispatch-to-system [:logger [:logger/flush]]]
[:rf.machine/destroy (re-frame.machines/machine-by-system-id :logger)]]}))
[:rf.machine/spawn <spec>] and [:rf.machine/destroy <actor-id>] are the surface; the API reference documents them in full. Two properties worth internalising:
- Spawning an unregistered
:machine-idfails closed. No snapshot, no id, no:start— the spawn rejects with:rf.error/machine-spawn-unregistered-type. There is no "spec-less spawn." - Destroy is silently idempotent. Destroying an already-gone actor is a no-op — no error, no second trace. The actor has exactly one Active → Stopped transition.
For the rare case of spawning from outside a handler (true boot time), wrap the :rf.machine/spawn in a one-shot event and dispatch-sync it. Inside a machine, prefer declarative :spawn; inside an ordinary handler, the imperative fx is the canonical surface.
Cooperative cancellation — teardown on every exit path¶
This is why :spawn is worth using instead of firing an HTTP request from a plain handler: the child's death is guaranteed on every code path out of its state. The runtime destroys a spawned actor on any of these triggers:
- Parent state exit — any transition out of the
:spawn-bearing state. - The parent's
:afterfiring — a wall-clock timeout (next section) is just a state exit. :spawn-alljoin resolution — surviving siblings are unconditionally torn down when the join resolves.- Imperative
[:rf.machine/destroy <id>]. - Frame destroy — when the frame is torn down by an explicit
destroy-frame!, every surviving machine in it is destroyed. Note that a frame is not destroyed by a view unmounting or by navigating away — frames survive unmount by design (tearing one down is a deliberatedestroy-frame!, not a cleanup effect). So this trigger fires when a component that owns a frame's whole lifetime destroys it (a modal with a throwaway world torn down on close), or when a per-request SSR frame is disposed at end-of-request — not on every route change. Cross-route teardown of a machine's resources rides its:exitcascade (triggers 1–4 above), which is why you don't wire abort calls into route-leave handlers even though the frame itself lives on.
What auto-cancels — and what you wire by hand¶
The runtime knows how to release exactly three framework-managed resource kinds on destroy, by any trigger:
- In-flight
:rf.http/managedrequests the actor issued — aborted automatically. The reply path sees{:kind :rf.http/aborted :reason :actor-destroyed}. This is the headline: an HTTP request issued from inside a spawned actor is bound to that actor's lifetime; a request fired from a plainreg-eventhandler has no such peg and is not cancelled. If you want lifetime-bound HTTP, spawn a child that issues it. - Armed
:aftertimers the actor scheduled — cancelled, so a killed worker won't wake up later. :rf.resource/*owner leases the actor holds.
Auto-cancel covers three resource kinds
re-frame2 auto-cancels exactly the three kinds above — the ones the framework manages, and so knows how to release. Anything else the child holds — a raw setInterval, a js/WebSocket, a Web Worker, a third-party SDK subscription — you release yourself, in the child's :exit action. It runs on every destroy cascade, so one line covers all five triggers:
{:connected
{:entry :open-socket
:exit :close-socket ;; runs on EVERY exit, including parent-destroy
:on {:disconnect :idle}}}
The destroy path runs the actor's :exit before dissociating its snapshot, so cleanup gets to read the final :data. There is no core.async in this path — close the host handle directly, don't reach for a channel close. (The websocket example keeps its mock socket in a host-side atom and leans on actor-destroy plus a mock that holds no real OS resource; a real js/WebSocket would .close() in :exit.)
The long-running-work example shows the cascade end-to-end: the user hitting Cancel dispatches :cancel, which leaves the :working state, and that exit fires a :rf.machine/destroy for every still-running child — pending :after yield-timers and all. The author wrote no per-child teardown.
Wall-clock timeouts — use the parent state's :after¶
"The whole activity must finish within N seconds, spanning any internal retries." There is no :timeout-ms slot on :spawn (it's rejected at registration). The answer is the :after primitive on the :spawn-bearing state — one timer mechanism, not two:
{:authenticating
{:spawn {:machine-id :login-request ;; a child MACHINE that issues the request and retries internally
:data {:request {:method :post :url "/api/login" :body credentials}}}
:after {30000 :auth-failed} ;; wall-clock guard — spans the child's retries
:on {:succeeded :authenticated
:user/cancelled :idle}}}
The timer is anchored to :authenticating's entry, so it bounds the child's whole lifetime no matter how many times the child retries internally. When it fires, the state exits and the standard cancellation cascade destroys the in-flight child (aborting its HTTP). Three independent triggers — the user cancels, 30s elapses, or the frame is torn down — all route through the same teardown.
The same guard has a named-intent spelling,
:timeout "PT30S"+:on-timeout {:target :auth-failed}(ISO-8601 durations), which lowers onto the same:aftertimer. Use whichever reads better; they're one mechanism. (Durations are ISO-8601 or integer milliseconds; a bare"5s"shorthand isn't accepted.)
Fan-out and join with :spawn-all¶
When a state needs to spawn N children in parallel and resume on a join condition — boot hydration, parallel asset loads, a swarm of workers — use :spawn-all. The long-running-work example is the canonical case: one parent coordinator spawns a worker per shard and joins when all are done.
(rf/reg-machine :work/flow
{:initial :idle
:data {:progress {}}
:states
{:idle
{:on {:start {:target :working :action :reset-progress}}}
: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 ;; :all (default) or :any
:on-child-done :work/child-done ;; keyword each child dispatches on success
:on-child-error :work/child-error ;; ...and on failure
:on-all-complete [:work/all-done] ;; parent event when :all resolves
:on-any-failed [:work/any-failed]} ;; parent event when a child fails
:on {;; A child checking in — no :target makes it an internal self-transition,
;; so the children aren't re-spawned and aren't torn down.
:progress {:action :record-progress}
;; The join landing back in the parent as an ordinary event:
:work/all-done {:target :complete :action :stamp-outcome}
:work/any-failed {:target :error :action :stamp-outcome}
;; The user pulling the plug — leaving :working destroys every child:
:cancel {:target :cancelled :action :stamp-outcome}}}
:complete {:on {:reset {:target :idle}}}
:cancelled {:on {:reset {:target :idle}}}
:error {:on {:reset {:target :idle}}}}})
The child completion protocol. Each child is an ordinary machine. When it finishes, its terminal state dispatches the parent's :on-child-done keyword, carrying its own :id:
;; In the child (:work/processor), at its terminal state:
:done {:entry (fn [{data :data}]
{:fx [[:dispatch [:work/flow [:work/child-done (:shard data)]]]]})}
The runtime intercepts these at the parent's boundary, updates join state, and once the condition resolves, fires the parent event (:on-all-complete here) and unconditionally cancels any siblings still in flight. The bookkeeping — who's done, who failed, the resolution latch — is runtime-owned at [:rf.runtime/machines :spawned <parent-id> <invoke-id>]; the parent keeps none of it in :data.
A few rules worth knowing:
- Each child needs a unique
:id(the join key) on top of the usual spawn keys. Duplicate ids are a registration error. - Join discriminators:
:all(default) or:any.:on-all-completeis required for:all;:on-some-completeis required for:any. A quorum ("N of M") join uses the data-only:after+:done-guardidiom, not a:joinmode (Spec 005 §Join semantics). - Sibling cancellation on join resolution is unconditional — when the join resolves, surviving siblings are torn down. A late result from an already-decided join fires no further parent event. A fan-out where each child is independently valuable is modelled as N independent single-
:spawns (fire-and-forget), not a non-cancelling join. - An unregistered child type fails the whole join closed before any join-state is seeded — a child that can never run can never deadlock an
:alljoin. - Wall-clock timeout: same as single
:spawn— an:afteron the:spawn-all-bearing state. When it fires, the exit cascade cancels every surviving child.
:spawn-all packages the fan-out, the join condition, and the cancel-on-resolution cascade into one declaration.