A type-safe, asynchronous finite state machine implementation for Swift, with powerful effect handling and SwiftUI integration.
Oak is actively developed and evolving. The library is well-tested and functional, with a stable core API.
We encourage developers to try Oak, provide feedback, and contribute to its evolution.
What Oak is:
- A Swift library for modeling application logic as finite-state machines (transducers).
- A unidirectional, event-driven, state-and-effect system with strong type safety.
- Clear separation of pure state transition logic from side effects (via Effects).
- A composable architecture that integrates with SwiftUI.
- A runtime that manages async work (managed tasks) and cancellation in a lifecycle-aware manner.
The example demonstrates how to define a state machine which uses effects:
Show Example
import Oak
// Simple, non-terminating counter state machine with
// async effects
enum EffectCounter: EffectTransducer {
// State holds counter value and tracks operation
// progress
struct State: NonTerminal {
enum Pending {
case none
case increment
case decrement
}
var value: Int = 0
var pending: Pending = .none
var isPending: Bool { pending != .none }
}
static var initialState: State { State() }
// Dependencies needed by effects
struct Env: Sendable {
init() {
self.serviceIncrement = {
try await Task.sleep(for: .seconds(1))
}
self.serviceDecrement = {
try await Task.sleep(for: .seconds(1))
}
}
var serviceIncrement: @Sendable () async throws -> Void
var serviceDecrement: @Sendable () async throws -> Void
}
// Events that trigger state transitions
enum Event {
case increment
case decrement
case reset
case incrementReady
case decrementReady
}
// Effect for increment: creates an operation effect
static func incrementEffect() -> Self.Effect {
Effect { env, input in
try await env.serviceIncrement()
try input.send(.incrementReady)
}
}
// Effect for decrement: creates an operation effect
static func decrementEffect() -> Self.Effect {
Effect(id: "decrement") { env, input in
try await env.serviceDecrement()
try input.send(.decrementReady)
}
}
// Core state transition logic: a pure function that
// handles events and returns effects
static func update(
_ state: inout State,
event: Event
) -> Self.Effect? {
switch (state.pending, event) {
case (.none, .increment):
state.pending = .increment
return incrementEffect()
case (.none, .decrement):
state.pending = .decrement
return decrementEffect()
case (.none, .reset):
state = State(value: 0, pending: .none)
return nil
case (.increment, .incrementReady):
state.value += 1
state.pending = .none
return nil
case (.decrement, .decrementReady):
state.value -= 1
state.pending = .none
return nil
// Ignore increment/decrement events during
// pending operation
case (_, .increment), (_, .decrement):
return nil
case (_, .reset):
state = State(value: 0, pending: .none)
return nil
default:
return nil
}
}
}- Type-safety: Leverage Swift's type system to catch invalid usage at compile time
- Actor isolation: Safe execution across different isolation contexts with automatic inference
- Side effect management: Structured handling of asynchronous side effects with proper cancellation
- Environment support: Provide typed environment to effects for dependencies and configuration
- Async/await support: Built on Swift's structured concurrency model
- SwiftUI integration: Reactive bindings with
TransducerView - Composability: Pipe transducer outputs into other transducer inputs
- Effect composition: Combine multiple effects sequentially or in parallel
- Back pressure support: In compositions, a connected output awaits readiness of the consumer
- Completion callbacks: Handle transducer completion with type-safe callbacks
TransducerView is a SwiftUI view that integrates transducers directly into your view hierarchy. It manages the transducer's lifecycle, automatically starting it when the view appears and cleaning up when the view disappears. The view reactively updates whenever the transducer's state changes. A TransducerView directly uses a view's @State as the transducer's state and utilizes SwiftUI's built-in diffing facility for efficient updates.
As a Transducer Actor, TransducerView combines the power of transducers with effect handling and the composability of SwiftUI views, enabling hierarchical transducer architectures where parent and child views can each manage their own state machines while participating in a coordinated view hierarchy.
Note:
TransducerViewcan replace conventional ViewModel implementations usingObservableObjector the Observation framework. This supports a "View only architecture" where traditional artifacts like Model, ViewModel, Router, and Interactor are consolidated and implemented directly as SwiftUI views.
struct CounterView: View {
@State private var state = SimpleCounter.State()
var body: some View {
TransducerView(
of: SimpleCounter.self,
initialState: $state
) { state, input in
VStack {
Text("Count: \(state.count)")
Button("Increment") {
try? input.send(.increment)
}
Button("Decrement") {
try? input.send(.decrement)
}
}
}
}
}Show Example
extension EnvironmentValues {
@Entry var effectCounterEnv: EffectCounter.Env = .init()
}
struct ContentView: View {
@Environment(\.effectCounterEnv) var env
@State private var state = EffectCounter.initialState
@State private var lastOutput: String = ""
var body: some View {
TransducerView(
of: EffectCounter.self,
initialState: $state,
env: env,
output: Callback { output in
lastOutput = "Last action: \(output)"
}
) { state, input in
VStack {
Text("Count: \(state.count)")
Text(lastOutput)
Button("Increment") { try? input.send(.increment) }
}
}
}
}Show Example
Handle transducer completion with type-safe callbacks that are called when the transducer finishes:struct ContentView: View {
@SwiftUI.State private var state = MyTransducer.State()
var body: some View {
TransducerView(
of: MyTransducer.self,
initialState: $state,
completion: { result in
switch result {
case .success(let output):
print("Transducer completed successfully with output: \(output)")
case .failure(let error):
print("Transducer failed with error: \(error)")
}
}
) { state, input in
// UI content
}
}
}Note: Completion callbacks are always invoked when the transducer finishes, whether it completes successfully or encounters an error. The callback receives a
Resultthat contains either the success value or the error.
ObservableTransducer is an @Observable class that wraps a transducer for use outside of SwiftUI views or when you need to share transducer state across multiple views. It provides reactive state management using the Observation framework, making it perfect for ViewModels or standalone state management.
All you need to do to support this pattern is to instantiate the Observable from the generic type ObservableTransducer and assign it a property in the view. You don't need to create the Observable class yourself anymore.
struct CounterView: View {
@State private var counter = ObservableTransducer(
of: CounterTransducer.self,
initialState: CounterTransducer.State(),
env: CounterTransducer.Env()
)
var body: some View {
VStack {
Text("Count: \(counter.state.count)")
Button("Increment") {
try? counter.proxy.send(.increment)
}
Button("Decrement") {
try? counter.proxy.send(.decrement)
}
}
}
}Note: The same Transducer definition, that is a type conforming to either
TransducerorEffectTransducer, can be used for aTransducerViewand/or for anObservableTransducer.
Oak includes experimental support for composing transducers at the type level. This advanced feature allows for the creation of composite state machines that maintain the type-safety guarantees of the component transducers.
This composition mechanism is still evolving, but it shows promise for building complex state management solutions with clean architectural boundaries. Various composition strategies (parallel, sequential, custom) are being explored to determine the most effective patterns for different use cases.
In Xcode, select File → Add Packages… and enter:
https://github.com/couchdeveloper/Oak.git
Or add to your Package.swift:
dependencies: [
.package(url: "https://github.com/couchdeveloper/Oak.git", from: "0.28.0")
]Oak uses transducers - finite state machines that process events and produce outputs. Here's how to create a simple counter transducer returning the counter's value as its output:
import Oak
// A basic counter transducer that outputs the current count.
enum SimpleCounter: Transducer {
struct State: NonTerminal {
var count: Int = 0
}
static var initialState: State { State() }
enum Event {
case increment
case decrement
}
enum Output {
case none
case value(Int)
}
static func update(_ state: inout State, event: Event) -> Output {
switch event {
case .increment:
state.count += 1
return .value(state.count)
case .decrement:
state.count -= 1
return .value(state.count)
}
}
}let proxy = SimpleCounter.Proxy()
let task = Task {
try await SimpleCounter.run(
initialState: SimpleCounter.initialState,
proxy: proxy
)
}
// Send events
try proxy.send(.increment)
try proxy.send(.decrement)To demonstrate the usage in SwiftUI, the counter transducer will be used using a TransducerView. The TransducerView has a Content view which receives the
current state and an "Input" for the transducer. The content view is responsible to render the state and send user intents to the transducer via the input.
In addition, the counter value can be optionally observed with a Callback which calls a closure that receives the current counter value:
import SwiftUI
struct ContentView: View {
@SwiftUI.State private var state = SimpleCounter.initialState
var body: some View {
TransducerView(
of: SimpleCounter.self,
initialState: $state,
output: Callback { output in
switch output {
case .none:
break
case .value(let count):
print("Count updated to: \(count)")
}
}
) { state, input in
VStack {
Text("Count: \(state.count)")
Button("Increment") {
try? input.send(.increment)
}
Button("Decrement") {
try? input.send(.decrement)
}
}
}
}
}This design allows the enclosing view, that is the ContentView in this example, to observe the output and also the transducer's state to react on it. It also allows the ContentView to orchestrate more than one transducer views in more complex scenarios.
If orchestrating becomes more complex, the ContentView may itself utilize a transducer that handles the state transitions and it will become the content view of it. That way, complex hierarchies can be built to solve complex UI scenarios.
Ready for more? Check out the Core Concepts section below!
A finite state machine that processes events and produces outputs with optional side effects.
Transducers are implemented as async throwing functions rather than classes with mutable state. This design enables better composability since functions can be composed more easily than objects, and eliminates the need for object lifecycle management.
A proxy provides an asynchronous event channel for sending events to the transducer and letting the transducer obtain these events in an async loop. A proxy is required to start a transducer:
let proxy = CounterTransducer.Proxy()
try await CounterTransducer.run(
initialState: CounterTransducer.initialState,
proxy: proxy
)It can also be used to send events and to forcibly cancel the transducer:
try proxy.send(.increment)
proxy.cancel()The state of the finite state machine must conform to Terminable.
A transducer can run with strictly encapsulated state or with shared state defined in an actor. For strictly encapsulated state, the initial state value is passed to the run function. If the state is shared, a binding is used, allowing external components to observe state changes.
struct State: Terminable {
var value: Int = 0
var isTerminal: Bool { value > 10 }
}For non-terminal states, conform to NonTerminal:
struct State: NonTerminal {
var value: Int = 0
}Input values that trigger state transitions in the finite state machine.
Events represent things that happen in your system - user actions, network responses, timer events, etc. They are processed by the transducer's update function to produce state changes and effects.
enum Event {
case userTapped
case dataReceived(Data)
case timeout
}Represents the input channel of a transducer. A proxy has an input property which provides access to the transducer's input channel.
An input is used to send events into the transducer. It's passed as a parameter to effect operations and can be shared with other components. Components that only have access to the input interface cannot forcibly cancel the transducer.
Effect(isolatedOperation: { _, input, _ in
try await Task.sleep(nanoseconds: 1_000_000_000)
try input.send(.ready)
})Values produced by the transducer's update function that can be observed externally, enabling communication with other components or UI updates.
typealias Output = Int
static func update(_ state: inout State, event: Event) -> Output {
state.counter += 1
return state.counter // Output value
}An effect is a special output of an effect transducer. It encapsulates an asynchronous operation that can perform side effects and send events back to the transducer.
typealias Env {}
static func update(_ state: inout State, event: Event) -> Self.Effect? {
// return an effect or nil
}Effects come in two forms:
- Actions: Synchronous functions that return events immediately
- Operations: Long-running, cancellable tasks that send events asynchronously
Action Effect:
static func createDelegate(param: Param) -> Effect {
let delegate = MyDelegate(param: param)
return .event(.delegate(delegate))
}Operation Effect:
static func incrementEffect() -> Effect {
Effect(isolatedOperation: { _, input, _ in
try await Task.sleep(nanoseconds: 1_000_000_000)
try input.send(.incrementReady)
})
}For global actor isolation:
static func incrementEffect() -> Effect {
Effect(operation: { @MyGlobalActor _, input in
try await Task.sleep(nanoseconds: 1_000_000_000)
try input.send(.incrementReady)
})
}A component that receives output values from a transducer, enabling communication with other parts of your application.
let output = Callback<Int> { value in
print("Received output: \(value)")
}
try await CounterTransducer.run(
initialState: state,
proxy: proxy,
output: output
)Oak makes state management explicit, predictable, and ergonomic while embracing Swift's modern concurrency features.
Apache License (v2.0) - See LICENSE file for details
Contributions are welcome! Please feel free to submit a Pull Request.
For contributors using AI coding assistants, see AI_GUIDELINES.md for concise, tool-agnostic rules tailored to this repository. It complements .github/copilot-instructions.md with norms on effects, terminal states, concurrency, and testing. Start here: AI_GUIDELINES.md.