A stateful workflow component that combines an accumulator with reactive rules.
A StateMachine maintains a piece of state via a reducer function and triggers
side-effects (reactors) whenever that state changes. Unlike the FSM component
which models discrete named states and transitions, StateMachine manages
arbitrary state values (counters, maps, lists, etc.) and reacts to them with
user-defined logic.
How It Works
At compile time a StateMachine is lowered into standard Runic primitives:
- An Accumulator that holds the current state value and applies the
:reducerfunction to each incoming fact to produce a new state. - One Rule per reactor. Each reactor observes the accumulator's current
value via
state_of()meta-references and fires whenever new state is produced.
This means a StateMachine participates in the workflow graph like any other set of Runic nodes — it is not a special runtime concept.
DSL Syntax
StateMachines are created with the Runic.state_machine/1 macro, passing a
keyword list of options:
Runic.state_machine(
name: :my_machine,
init: initial_value,
reducer: fn input, acc -> new_acc end,
reactors: [...]
)Options
:name— atom name for the state machine (required).:init— initial state value. Accepts a literal (auto-wrapped into a zero-arity function) or an explicitfn -> value endthunk.:reducer— a 2-arity functionfn input, accumulator -> new_accumulator end. Supportscontext/1expressions for accessing runtime context values.:reactors— a list of reactor functions or a keyword list of named reactors. Unnamed reactors are auto-named:"<sm_name>_reactor_<idx>". Each reactor receives the current state and may return a derived fact. Reactors also supportcontext/1expressions.
Examples
require Runic
# Basic counter with a named reactor
sm = Runic.state_machine(
name: :counter,
init: 0,
reducer: fn x, acc -> acc + x end,
reactors: [
alert: fn count -> if count > 10, do: {:alert, count} end
]
)
# Literal init value (auto-wrapped to thunk)
sm = Runic.state_machine(
name: :collector,
init: [],
reducer: fn item, items -> [item | items] end,
reactors: [fn items -> length(items) end]
)
# Using runtime context in reducer and reactors
sm = Runic.state_machine(
name: :scaled,
init: 0,
reducer: fn x, acc -> acc + x * context(:multiplier) end,
reactors: [
log: fn state -> {context(:logger), state} end
]
)Block DSL with handle/react (Form 2)
For state machines with complex state and event-driven transitions, the
block DSL provides a more expressive form. Each handle clause bundles
an event match, input pattern, state binding, and state transformation
into a named, addressable sub-component. react clauses observe state
without modifying it.
Runic.state_machine name: :cart, init: %{items: [], total: 0} do
handle :add_item, %{item: item}, state do
%{state | items: [item | state.items], total: state.total + item.price}
end
handle :checkout, _, state when state.items != [] do
%{state | status: :checked_out}
end
react :high_value do
fn %{total: t} when t > 1000 -> {:vip_alert, t} end
end
endhandle clause semantics
handle event_pattern, input_match, state_var [when state_guard] do
body # must return next state
endevent_pattern— atom or pattern matched against the incoming fact's event type discriminator.input_match— pattern match on the event payload / fact value.state_var— binds the current state viastate_of(:sm_name)meta_ref.when state_guard— optional guard on current state.body— returns the next state value, fed to the accumulator.
Each handle compiles to a named Rule:
:"<sm_name>_<event_pattern>" (e.g., :cart_add_item).
react clause semantics
react name do
fn state_pattern -> output end
end- Name is explicitly required (the atom after
react). - Compiles to a Rule with a
state_of()condition and a step that produces an output fact. - Does not modify state — observation only.
Compilation equivalence
Both the keyword form (Form 1) and the block DSL (Form 2) produce
identical %StateMachine{} structs. The handle block is sugar for
splitting a multi-clause reducer into individually named rules.
Sub-Component Access
After adding a StateMachine to a workflow, its internal primitives can be
retrieved via Workflow.get_component/2 using a {name, kind} tuple:
alias Runic.Workflow
wrk = Workflow.new() |> Workflow.add(sm)
# Get the underlying accumulator
[accumulator] = Workflow.get_component(wrk, {:counter, :accumulator})
# Get all reactor rules
reactor_rules = Workflow.get_component(wrk, {:counter, :reactor})