philiprehberger/swift-state-kit
Type-safe async state machine with built-in logging and SwiftUI bindings
Requirements
- Swift >= 6.0
- macOS 13+ / iOS 16+ / tvOS 16+ / watchOS 9+
Installation
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/philiprehberger/swift-state-kit.git", from: "0.1.0")
]Then add "StateKit" to your target dependencies:
.target(name: "YourTarget", dependencies: [
.product(name: "StateKit", package: "swift-state-kit")
])Usage
import StateKit
// Define states and events
enum OrderState: Hashable, Sendable {
case pending, confirmed, shipped, delivered
}
enum OrderEvent: Hashable, Sendable {
case confirm, ship, deliver
}
// Define transitions
let machine = StateMachine(
initial: OrderState.pending,
transitions: [
Transition(from: .pending, on: .confirm, to: .confirmed),
Transition(from: .confirmed, on: .ship, to: .shipped),
Transition(from: .shipped, on: .deliver, to: .delivered)
]
)
let state = try await machine.send(.confirm) // => .confirmedBuilder DSL
import StateKit
let machine = StateMachine<OrderState, OrderEvent>(initial: .pending) {
Transition(from: .pending, on: .confirm, to: .confirmed)
Transition(from: .confirmed, on: .ship, to: .shipped)
Transition(from: .shipped, on: .deliver, to: .delivered)
// Multi-source helper expands into one transition per source
Transition.from([.pending, .confirmed, .shipped], on: .cancel, to: .cancelled)
}The @TransitionBuilder result builder also supports if, if let, if/else, and for blocks inside the closure.
Waiting for a State
import StateKit
// Suspend until the machine reaches .delivered (optionally with a timeout)
let final = try await machine.waitFor(.delivered, timeout: .seconds(60))Returns immediately if the machine is already in the target state. Throws StateMachineError.waitTimeout if the optional duration elapses first.
Previewing a Transition
import StateKit
// Resolve where an event *would* lead — honoring guards — without transitioning
if let next = await machine.peek(.confirm) {
print("Confirm would move to \(next)")
}peek(:) runs the same matching and guard logic as send(:) but never mutates state, runs side effects, or records history. Returns nil if no transition applies. Unlike canSend(_:), it evaluates guard conditions and returns the destination state.
Sending a Sequence of Events
import StateKit
// Apply several events in order, returning the final state
let final = try await machine.send([.confirm, .ship, .deliver]) // => .deliveredEvents are applied one at a time. If an event is invalid in the state reached so far, send(_:) throws and the events applied before it remain committed.
Async Side Effects
Transition(from: .pending, on: .confirm, to: .confirmed) {
try await sendConfirmationEmail()
}Logging
let machine = StateMachine(
initial: OrderState.pending,
transitions: transitions,
logger: .console
)
// Logs: "[StateKit] pending --confirm--> confirmed"Timeout Transitions
import StateKit
// Auto-transition to error after 30 seconds in loading state
await machine.addTimeout(TimeoutTransition(
from: .loading, after: .seconds(30), on: .timeout, to: .error
))Timeouts auto-cancel if the state changes before the duration expires.
State Persistence
import StateKit
// Save state
let snapshot = await machine.snapshot()
let data = try JSONEncoder().encode(snapshot)
// Restore state
let decoded = try JSONDecoder().decode(StateMachineSnapshot<OrderState>.self, from: data)
try await machine.restore(from: decoded)Middleware
import StateKit
struct AuthMiddleware: TransitionMiddleware {
func intercept(
from: OrderState, event: OrderEvent, to: OrderState,
next: @Sendable () async throws -> Void
) async throws {
guard await isAuthorized() else { throw AuthError.denied }
try await next()
}
}
await machine.addMiddleware(AuthMiddleware())Middleware runs in order. Each must call next() to proceed or throw to reject.
Entry and Exit Actions
import StateKit
let machine = StateMachine(initial: OrderState.pending, transitions: transitions)
await machine.onEnter(.shipped) {
try await sendTrackingNotification()
}
await machine.onExit(.pending) {
try await logOrderStart()
}Exit actions run before the state changes, entry actions run after.
Async State Streams
import StateKit
let machine = StateMachine(initial: OrderState.pending, transitions: transitions)
// Observe state changes reactively
Task {
for await state in await machine.stateStream {
print("State changed to: \(state)")
}
}
// Or observe full transitions
Task {
for await (from, event, to) in await machine.transitionStream {
print("\(from) --\(event)--> \(to)")
}
}Wildcard Transitions
import StateKit
// Matches from any state — useful for global events like reset
let transitions = [
Transition(from: .idle, on: .start, to: .loading),
Transition(from: .loading, on: .succeed, to: .loaded),
Transition(fromAny: .reset, to: .idle) // works from any state
]Specific transitions are always checked before wildcards.
Guard Conditions
import StateKit
let transitions = [
Transition(from: .idle, on: .start, to: .loading, guard: { await isNetworkAvailable() }),
Transition(from: .idle, on: .start, to: .error, guard: { true }) // fallback
]When multiple transitions match the same state and event, guard conditions are evaluated in order. The first transition whose guard returns true is taken.
State History and Undo
import StateKit
let machine = StateMachine(
initial: OrderState.pending,
transitions: transitions,
historyDepth: 0 // 0 = unlimited, nil = disabled
)
try await machine.send(.confirm)
try await machine.send(.ship)
// Inspect history
let history = await machine.history // [pending→confirmed, confirmed→shipped]
// Undo last transition
let restored = try await machine.undo() // => .confirmedSwiftUI Integration
struct OrderView: View {
@State private var machine: ObservableStateMachine<OrderState, OrderEvent>?
var body: some View {
if let machine {
VStack {
Text("Status: \(machine.state)")
Button("Confirm") { Task { try await machine.send(.confirm) } }
}
}
}
}API
StateMachine
| Method | Description | |--------|-------------| | init(initial:transitions:logger:historyDepth:) | Create a state machine with initial state and transitions | | send(:) | Send an event to trigger a transition | | send(:) (array) | Send a sequence of events in order, returning the final state | | peek(:) | Resolve the destination state for an event (honoring guards) without transitioning | | canSend(:) | Check if an event is valid in the current state | | waitFor(:timeout:) | Suspend until the machine reaches a target state, with optional timeout | | undo() | Revert to the previous state (requires history) | | onTransition(:) | Register a callback for state changes | | onEnter(:perform:) | Register an action for when a state is entered | | onExit(:perform:) | Register an action for when a state is exited | | addMiddleware(:) | Add a middleware to the transition pipeline | | reset() | Reset to initial state, clearing history | | validEvents | Set of events valid in the current state | | validEvents(for:) | Set of events valid for a given state | | validate() | Check transition table for duplicates and terminal states | | snapshot() | Create a Codable snapshot of the current state | | restore(from:) | Restore state from a snapshot | | addTimeout(:) | Register an automatic timeout transition | | metrics | Transition metrics (if enabled) | | resetMetrics() | Reset metrics counters | | exportDOT() | Export transition graph as Graphviz DOT | | exportMermaid() | Export transition graph as Mermaid diagram | | attach(child:to:) | Attach a child state machine to a parent state | | currentState | The current state | | initialState | The initial state the machine was created with | | history | Array of past transitions | | canUndo | Whether an undo operation is available | | stateStream | AsyncStream<State> emitting new states after transitions | | transitionStream | AsyncStream of (from, event, to) tuples |
Transition
| Property | Description | |----------|-------------| | init(from:on:to:guard:sideEffect:) | Create a transition from a specific state | | init(fromAny:to:guard:sideEffect:) | Create a wildcard transition from any state | | from(_:on:to:guard:sideEffect:) | Static helper expanding a list of source states into one transition each | | from | Source state (nil for wildcard) | | event | Triggering event | | to | Destination state | | guardCondition | Optional async predicate that must return true for the transition | | sideEffect | Optional async closure executed during transition |
StateMachine builder init
| Init | Description | |------|-------------| | init(initial:logger:historyDepth:enableMetrics:_:) | Convenience initializer accepting a @TransitionBuilder closure |
ObservableStateMachine
| Property/Method | Description | |-----------------|-------------| | init(machine:) | Create wrapper, reading initial state from the machine | | init(machine:initialState:) | Create wrapper with explicit initial state | | state | Current state (observable) | | isTransitioning | Whether a transition is in progress | | send(:) | Send an event | | canSend(:) | Check if an event is valid | | undo() | Revert to the previous state | | canUndo | Whether an undo operation is available |
Development
swift build
swift testSupport
If you find this project useful:
License
Package Metadata
Repository: philiprehberger/swift-state-kit
Default branch: main
README: README.md