🧑🎤 Actor + 🤖 Automaton = 🎭 Actomaton
Actomaton is Swift async/await & Actor-powered effectful state-management framework
inspired by Elm and swift-composable-architecture.
This repository consists of 6 modules:
-
Actomaton: Actor-based effect-handling state-machine, which is a convenience layer that wiresActomatonCore+ActomatonEffecttogether. -
ActomatonUI: SwiftUI & UIKit & Combine support. -
ActomatonDebugging: Helper module to printActionandState(with diffing) perReducercall. -
ActomatonTesting:TestActomaton— exhaustive state-transition testing utility. WrapsMealyMachine+EffectManagerto assert TCA-likesend/receiveflows withcustomDumpdiffs.
In addition, the following lower-level modules are available for advanced use cases:
ActomatonCore: Generic Mealy machine (MealyMachine) and composableMealyReducer, independent of any effect system. Pair it with a pluggableEffectManagerconformer to choose what "output" means —Void(no effects),Action?(synchronous feedback), or your own custom type.ActomatonEffect: TheEffect<Action>type and its defaultEffectManagerconformer (EffectQueueManager) — queue-based async task lifecycle (creation, cancellation, suspension, delay).
ActomatonCore -- generic MealyMachine + MealyReducer + EffectManager
└─ ActomatonEffect -- Effect<Action>, EffectQueueManager, EffectQueue, EffectID
├─ Actomaton -- Actomaton typealias, Reducer typealias, CasePath integration
│ ├─ ActomatonUI -- Store, RouteStore (SwiftUI / UIKit)
│ └─ ActomatonDebugging -- debug / log reducers
└─ ActomatonTesting -- TestActomaton (exhaustive state-transition testing)
ActomatonCore is intentionally free of Effect and async task management.
This makes MealyMachine usable in contexts where full effect infrastructure is overkill — for example, purely synchronous state machines, game logic, or protocol parsers.
The default EffectManager conformer ships in ActomatonEffect:
| Conformer | Output type | Use case |
|---|---|---|
EffectQueueManager (package) |
Effect<Action> |
Full async effect lifecycle (in ActomatonEffect) |
You can also provide your own EffectManager conformer to plug in a custom output type (e.g. Void for purely synchronous state machines, or [Action] for synchronous action feedback loops).
- Apple platforms: Full package support, including
ActomatonUI. - Linux / Wasm: Full package except
ActomatonUIwhich depends on Combine, SwiftUI, and UIKit.
In Package.swift:
let package = Package(
...
dependencies: [
.package(url: "https://github.com/Actomaton/Actomaton", .branch("main"))
]
)make wasm-install
make wasm-build
make wasm-teststruct State: Sendable {
var count: Int = 0
}
enum Action: Sendable {
case increment
case decrement
}
typealias Environment = Void
let reducer: Reducer<Action, State, Environment, Never>
reducer = Reducer { action, state, environment in
switch action {
case .increment:
state.count += 1
return Effect.empty
case .decrement:
state.count -= 1
return Effect.empty
}
}
let actomaton = Actomaton<Action, State, Never>(
state: State(),
reducer: reducer
)
@main
enum Main {
static func main() async {
assertEqual(await actomaton.state.count, 0)
await actomaton.send(.increment)
assertEqual(await actomaton.state.count, 1)
await actomaton.send(.increment)
assertEqual(await actomaton.state.count, 2)
await actomaton.send(.decrement)
assertEqual(await actomaton.state.count, 1)
await actomaton.send(.decrement)
assertEqual(await actomaton.state.count, 0)
}
}If you want to do some logging (side-effect), add Effect in Reducer as follows:
reducer = Reducer { action, state, environment in
switch action {
case .increment:
state.count += 1
return Effect.fireAndForget { _ in
print("increment")
}
case .decrement:
state.count -= 1
return Effect.fireAndForget { context in
print("decrement and sleep...")
try await context.clock.sleep(for: .seconds(1))
print("I'm awake!")
}
}
}NOTE: There are 6 ways of creating Effect in Actomaton:
- No side-effects, but next action only
Effect.next(action:)
- Single
asyncwithout next actionEffect.fireAndForget(id:run:)
- Single
asyncwith next actionEffect.init(id:run:)
- Multiple
asyncs (i.e.AsyncSequence) with next actionsEffect.sequence(id:_:)
- Push-style stream that emits next actions via a
sendclosureEffect.stream(id:_:)— useful for bridging long-lived observers (delegates, callbacks,NotificationCenter, etc.)
- Manual cancellation
Effect.cancel(id:)/.cancel(ids:)
Effects can also be combined with +: Effect.cancel(id: ...) + Effect { ... } runs them as a single effect.
typealias State = Int
enum Action: Sendable {
case start, tick, stop
}
struct TimerID: EffectID {}
struct Environment: Sendable {
let timer: @Sendable () -> AsyncStream<Void>
}
let environment = Environment(
timer: {
AsyncStream<Void> { continuation in
let task = Task {
while true {
try await Task.sleep(/* 1 sec */)
continuation.yield(())
}
}
continuation.onTermination = { @Sendable _ in
task.cancel()
}
}
}
)
let reducer = Reducer { action, state, environment in
switch action {
case .start:
return Effect.sequence(id: TimerID()) { _ in
environment.timer()
.map { _ in Action.tick }
}
case .tick:
state += 1
return .empty
case .stop:
return Effect.cancel(id: TimerID())
}
}
let actomaton = Actomaton<Action, State, Never>(
state: 0,
reducer: reducer,
environment: environment
)
@main
enum Main {
static func test_timer() async {
assertEqual(await actomaton.state, 0)
await actomaton.send(.start)
assertEqual(await actomaton.state, 0)
try await Task.sleep(/* 1 sec */)
assertEqual(await actomaton.state, 1)
try await Task.sleep(/* 1 sec */)
assertEqual(await actomaton.state, 2)
try await Task.sleep(/* 1 sec */)
assertEqual(await actomaton.state, 3)
await actomaton.send(.stop)
try await Task.sleep(/* long enough */)
assertEqual(await actomaton.state, 3,
"Should not increment because timer is stopped.")
}
}Here we see the notions of EffectID, Environment, and Effect.sequence:
EffectIDtags an effect with aHashableidentifier so it can be cancelled later viaEffect.cancel(id:). In this example,TimerIDlets.stopcancel the running timer.Environmentholds the raw dependencies (here, anAsyncStream-producing closure). TheReduceris responsible for wrapping those dependencies inEffect.Environmentis known as Dependency Injection Container (using Reader monad).Effect.sequence(id:_:)turns anAsyncSequenceinto anEffect, yieldingAction.tickmultiple times until cancelled.
EffectID (introduced in Example 1-2) only supports manual cancellation. When you instead want effects sharing the same identity to be cancelled (or suspended) automatically as new ones arrive, attach an EffectQueue.
enum State: Sendable {
case loggedOut, loggingIn, loggedIn, loggingOut
}
enum Action: Sendable {
case login, loginOK, logout, logoutOK
case forceLogout
}
// NOTE:
// By attaching this `EffectQueue` to multiple `Effect`s, they share the same
// `EffectQueuePolicy` — here, `Newest1EffectQueue` lets only the newest
// effect survive and automatically cancels older queued effects.
struct LoginFlowEffectQueue: Newest1EffectQueue {}
struct Environment: Sendable {
let login: @Sendable (_ userId: String) async throws -> Void
let logout: @Sendable () async throws -> Void
}
let environment = Environment(
login: { userId in
let loginRequest = ...
_ = try? await URLSession.shared.data(for: loginRequest)
...
},
logout: {
let logoutRequest = ...
_ = try? await URLSession.shared.data(for: logoutRequest)
...
}
)
let reducer = Reducer { action, state, environment in
switch (action, state) {
case (.login, .loggedOut):
state = .loggingIn
return Effect(queue: LoginFlowEffectQueue()) { _ in
try await environment.login("user-123")
return Action.loginOK
}
case (.loginOK, .loggingIn):
state = .loggedIn
return .empty
case (.logout, .loggedIn),
(.forceLogout, .loggingIn),
(.forceLogout, .loggedIn):
state = .loggingOut
return Effect(queue: LoginFlowEffectQueue()) { _ in
try await environment.logout()
return Action.logoutOK
}
case (.logoutOK, .loggingOut):
state = .loggedOut
return .empty
default:
return Effect.fireAndForget { _ in
print("State transition failed...")
}
}
}
let actomaton = Actomaton<Action, State, Never>(
state: .loggedOut,
reducer: reducer,
environment: environment
)
@main
enum Main {
static func test_login_logout() async {
var t: Task<(), Error>?
assertEqual(await actomaton.state, .loggedOut)
t = await actomaton.send(.login)
assertEqual(await actomaton.state, .loggingIn)
await t?.value // wait for previous effect
assertEqual(await actomaton.state, .loggedIn)
t = await actomaton.send(.logout)
assertEqual(await actomaton.state, .loggingOut)
await t?.value // wait for previous effect
assertEqual(await actomaton.state, .loggedOut)
XCTAssertFalse(isLoginCancelled)
}
static func test_login_forceLogout() async throws {
var t: Task<(), Error>?
assertEqual(await actomaton.state, .loggedOut)
await actomaton.send(.login)
assertEqual(await actomaton.state, .loggingIn)
// Wait for a while and interrupt by `forceLogout`.
// Login's effect will be automatically cancelled because of same `EffectQueue`.
try await Task.sleep(/* 1 ms */)
t = await actomaton.send(.forceLogout)
assertEqual(await actomaton.state, .loggingOut)
await t?.value // wait for previous effect
assertEqual(await actomaton.state, .loggedOut)
}
}Here we see the notions of EffectQueue and the Task<(), Error> returned from actomaton.send(...):
EffectQueueis for automatic cancellation or suspension of effects. In this example,Newest1EffectQueueis used so that only the newest 1 effect (forceLogout) will survive, and the rest of older queued effects (e.g. an in-flightlogin) will be automatically cancelled.- (Optional)
Task<(), Error>returned fromactomaton.send(action)is another fancy way of dealing with "all the effects triggered byaction". We can callawait task.valueto wait for all of them to be completed, ortask.cancel()to cancel all. Note thatActomatonalready manages suchtasks for us internally, so we normally don't need to handle them by ourselves (use this as a last resort!).
enum Action: Sendable {
case fetch(id: String)
case _didFetch(Data)
}
struct State: Sendable {} // no state
struct Environment: Sendable {
let fetch: @Sendable (_ id: String) async throws -> Data
}
struct DelayedEffectQueue: EffectQueue {
// First 3 effects will run concurrently, and other sent effects will be suspended.
var effectQueuePolicy: EffectQueuePolicy {
.runOldest(maxCount: 3, .suspendNew)
}
// Adds delay between effect start. (This is useful for throttling / debouncing)
var effectQueueDelay: EffectQueueDelay {
.random(0.1 ... 0.3)
}
}
let reducer = Reducer<Action, State, Environment, Never> { action, state, environment in
switch action {
case let .fetch(id):
return Effect(queue: DelayedEffectQueue()) { _ in
let data = try await environment.fetch(id)
return ._didFetch(data)
}
case let ._didFetch(data):
// Do something with `data`.
return .empty
}
}
let actomaton = Actomaton<Action, State, Never>(
state: State(),
reducer: reducer,
environment: Environment(fetch: { /* ... */ })
)
await actomaton.send(.fetch(id: "item1"))
await actomaton.send(.fetch(id: "item2")) // min delay of 0.1
await actomaton.send(.fetch(id: "item3")) // min delay of 0.1 (after item2 actually starts)
await actomaton.send(.fetch(id: "item4")) // starts when item1 or 2 or 3 finishesAbove code uses a custom DelayedEffectQueue that conforms to EffectQueue with suspendable EffectQueuePolicy and delays between each effect by EffectQueueDelay.
See EffectQueuePolicy for how each policy takes different queueing strategy for effects.
/// `EffectQueue`'s buffering policy.
public enum EffectQueuePolicy: Hashable, Sendable
{
/// Runs `maxCount` newest effects, cancelling old running effects.
case runNewest(maxCount: Int)
/// Runs `maxCount` old effects with either suspending or discarding new effects.
case runOldest(maxCount: Int, OverflowPolicy)
public enum OverflowPolicy: Sendable
{
/// Suspends new effects when `.runOldest` `maxCount` of old effects is reached until one of them is completed.
case suspendNew
/// Discards new effects when `.runOldest` `maxCount` of old effects is reached until one of them is completed.
case discardNew
}
}For convenient EffectQueue protocol conformance, there are built-in sub-protocols:
/// A helper protocol where `effectQueuePolicy` is set to `.runNewest(maxCount: 1)`.
public protocol Newest1EffectQueue: EffectQueue {}
/// A helper protocol where `effectQueuePolicy` is set to `.runOldest(maxCount: 1, .discardNew)`.
public protocol Oldest1DiscardNewEffectQueue: EffectQueue {}
/// A helper protocol where `effectQueuePolicy` is set to `.runOldest(maxCount: 1, .suspendNew)`.
public protocol Oldest1SuspendNewEffectQueue: EffectQueue {}so that we can write in one-liner: struct MyEffectQueue: Newest1EffectQueue {}
Actomaton-Gallery provides a good example of how Reducers can be combined together into one big Reducer using Reducer.combine.
In this example, swift-case-paths is used as a counterpart of WritableKeyPath, so if we use both, we can easily construct Mega-Reducer without a hassle.
(NOTE: CasePath is useful when dealing with enums, e.g. enum Action and enum Current in this example)
enum Root {} // just a namespace
extension Root {
enum Action: Sendable {
case changeCurrent(State.Current?)
case counter(Counter.Action)
case stopwatch(Stopwatch.Action)
case stateDiagram(StateDiagram.Action)
case todo(Todo.Action)
case github(GitHub.Action)
}
struct State: Equatable, Sendable {
var current: Current?
// Current screen (NOTE: enum, so only 1 screen will appear)
enum Current: Equatable {
case counter(Counter.State)
case stopwatch(Stopwatch.State)
case stateDiagram(StateDiagram.State)
case todo(Todo.State)
case github(GitHub.State)
}
}
// NOTE: `contramap` is also called `pullback` in swift-composable-architecture.
static var reducer: Reducer<Action, State, Environment, Never> {
Reducer.combine(
Counter.reducer
.contramap(action: /Action.counter)
.contramap(state: /State.Current.counter)
.contramap(state: \State.current)
.contramap(environment: { _ in () }),
Todo.reducer
.contramap(action: /Action.todo)
.contramap(state: /State.Current.todo)
.contramap(state: \State.current)
.contramap(environment: { _ in () }),
StateDiagram.reducer
.contramap(action: /Action.stateDiagram)
.contramap(state: /State.Current.stateDiagram)
.contramap(state: \State.current)
.contramap(environment: { _ in () }),
Stopwatch.reducer
.contramap(action: /Action.stopwatch)
.contramap(state: /State.Current.stopwatch)
.contramap(state: \State.current)
.contramap(environment: { $0.stopwatch }),
GitHub.reducer
.contramap(action: /Action.github)
.contramap(state: /State.Current.github)
.contramap(state: \State.current)
.contramap(environment: { $0.github })
)
}
}To learn more about CasePath, visit the official site and tutorials:
Store (from ActomatonUI.framework) provides a thin wrapper of Actomaton to work seamlessly in SwiftUI and UIKit world.
To find out more, check the following resources:
- Actomaton-Gallery (example apps)
- ActomatonUI | Documentation
- RouteStore チュートリアル | Documentation (in Japanese)
- Functional iOS Architecture for SwiftUI - Speaker Deck
- Functional iOS Architecture for SwiftUI (English)
- Swift アクターモデルと Elm Architecture の融合 (Japanese)
Actomaton is a successor of the following projects:
- Harvest (using Combine with SwiftUI support)
- ReactiveAutomaton (using ReactiveSwift)
- RxAutomaton (using RxSwift)