Contents

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)  // => .confirmed

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()  // => .confirmed

SwiftUI 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 | | canSend(:) | Check if an event is valid in the current state | | 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 | 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 |

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 test

Support

If you find this project useful:

Star the repo

🐛 Report issues

💡 Suggest features

❤️ Sponsor development

🌐 All Open Source Projects

💻 GitHub Profile

🔗 LinkedIn Profile

License

MIT

Package Metadata

Repository: philiprehberger/swift-state-kit

Default branch: main

README: README.md