Status: Beta (v1.0 Release Candidate) — Production-ready for iOS, macos, SwiftUI.
Transduce is a deterministic reactive runtime with a pure state machine at its core. The runtime owns all concurrency, task management, synchronization, and effect execution, leaving the developer—or an LLM—to specify only state transitions and business rules.
What it is:
- A library, not a full application framework
- The essential components for building large SwiftUI apps with structured state management
- A pattern-based approach (FSM/MVI) that works with SwiftUI's native observation system
What it isn't:
- ❌ Not a complete application framework (you still need network code, database, etc.)
- ❌ Not a testing framework (you still need mocking frameworks, test doubles, etc.)
- ❌ Not a vendor lock-in solution (no large codebase, no slow build times)
The library provides:
- ✅ Structured effect management (tasks, actions, cancellation)
- ✅ Pure
transducefunctions for testable business logic - ✅ Dependency injection via
Env - ✅ Task lifecycle management (auto-cancellation, overlap policies)
You still need to provide:
- Implementation details (network clients, database access, etc.)
- Testing infrastructure (mocking frameworks, test doubles, etc.)
- App architecture decisions (feature boundaries, module organization)
This library enforces conventions over creativity — not to restrict developers, but to enable reliable AI-assisted development:
- What it enforces: State, Event, Env, Response, transduce function structure
- What it enables: AI can reliably generate and review code because patterns are consistent
- Why this is valuable: Focus on business logic, not boilerplate; everyone knows where to find what
This is not about limiting creativity - it's about focusing creativity on what matters: business logic, not architecture.
import SwiftUI
import Transduce
enum Counter: Transducer {
struct State: Equatable { var count = 0 }
enum Event: Equatable { case increment, decrement }
static let initialState: State = .init()
static func transduce(_ state: inout State, event: Event) -> Effect {
switch event {
case .increment: state.count += 1
case .decrement: state.count -= 1
}
return .none
}
}
struct ContentView: View {
@State private var state = Counter.State()
var body: some View {
EffectView(of: Counter.self, state: $state) { state, input in
HStack {
Button("-") { try? input(.decrement) }
Text("\(state.count)")
Button("+") { try? input(.increment) }
}
}
}
}A reactive transducer reduces events into state transitions and side effects. In short:
δ : S × E → F // State × Event → Effect (describe work to do)
λ : S × E × S' → O // Updated State, Event → Response (produce output)
- A pure
transducefunction decides all state changes — it is the sole mutation point. - Structured effects: start/cancel tasks, run action chains, or sequence operations—declaratively.
- Managed lifetime via hosts: SwiftUI
EffectViewand other hosts like Observables or Actors. - Response is derived purely from State and Event.
- Clear async semantics at the call site with
post,send, andrequest.
Transduce is SwiftUI-first but not SwiftUI-only—the core model is entirely UI-agnostic.
.package(url: "https://github.com/couchdeveloper/EffectComponents.git", from: "0.10.0")Add Transduce to your target dependencies.
// Target
.target(
name: "YourApp",
dependencies: [
.product(name: "Transduce", package: "EffectComponents")
]
)| Concept | What it does |
|---|---|
| Transducer | (inout State, Event) → Effect — the single place that mutates state and describes follow-up work. |
| Effect | A declarative description of what to do next (.task, .cancel, .action, .sequence, .none). The runtime performs it; your code only describes intent. |
| Host | Owns task lifetime and routes events (EffectView for SwiftUI, BaseRuntime as base class). |
| Env | Immutable dependency capture at host initialization — forwarded to every effect invocation. |
Dispatch styles: post (fire-and-forget), send (await processing), request (await result + return a value).
| Module | Status | Purpose |
|---|---|---|
| Transduce | Public (v1.0) | Effect runtime — transducer types, effect system, event dispatch, task lifecycle, non-Combine observation layer. |
| Expect | Internal only | Lightweight expectation testing (.expect() on Result). Only used internally by this package's tests. Not published as a standalone API at this time. |
Supported platforms: iOS 15+, macOS 12+, watchOS v9, tvOS 15+, macCatalyst 15+.
Pass dependencies — clocks, API clients, schedulers — via Env. The value is captured once when the host initializes and forwarded to every effect. No singletons:
struct Dependencies { var api: APIClient; var clock: AnyClock }
enum MoviesFeature: Transducer {
enum State { case idle, loading, loaded([Movie]), error(Error) }
enum Event { case loadRequested, moviesLoaded([Movie]), loadFailed(Error) }
typealias Env = Dependencies
static let initialState: State = .idle
static func transduce(_ state: inout State, event: Event) -> Effect {
switch (state, event) {
case (.idle, .loadRequested):
state = .loading
return .task(id: "load") { input, env in
let movies = try await env.api.fetch()
try? input(.moviesLoaded(movies))
}
case (_, .moviesLoaded(let movies)):
state = .loaded(movies)
return .none
default:
return .none
}
}
}Env changes are handled via .id(envId) at the call site — this destroys the old view (cancelling all tasks) and creates a fresh instance with updated dependencies.
Transduce excels at solving difficult dependency management problems where standard singletons typically fail — for example, network requests racing against async database initialization.
When managing CoreData migrations or heavy network managers, you usually have to build custom DispatchGroups, semaphores, or NSNotifications just to block early requests until the manager is ready.
Because Transduce processes events through a strict queue, if multiple clients call input.send(.fetch) while the manager is initializing, those requests simply "hang" in the event backlog. They resume only after your initialization task emits a .didInit event — serializing access across components without global locks or race conditions.
// ─── Database Repository Transducer ───
enum DatabaseManager: Transducer {
enum State { case loading, ready }
enum Event { case startMigration, didInit, fetchData }
static let initialState: State = .loading
// Note: We use `task` internally to bootstrap the manager
static func transduce(_ state: inout State, event: Event) -> Effect {
switch (state, event) {
case (.loading, .startMigration):
// Performs async setup. Until this emits `.didInit`,
// ALL subsequent requests buffer automatically in the queue!
return .task(id: "init") { [weak input] _, _ in
try await performHeavyCoreDataMigration()
if let i = input { try await i.post(.didInit) }
}
case (.ready, .fetchData):
// This only fires once the `.managerReady` event is received
return .none
}
}
}Use Transduce's call-site (caller-owned) tasks to manage HTTP requests exactly like URLSession, but with deterministic lifecycle control. If the user navigates away during a request, the task is automatically cancelled and unmounted—preventing dangling closures or crashed view states that standard singletons struggle to track.
This pattern scales beautifully to:
- Multi-server clients with strict state up/down sequences (e.g., handshake → auth → sync)
- Downloader managers where requests can hang in the queue waiting for bandwidth or credentials
- Authenticators that tear down stale sessions and enforce clean re-initialization without race conditions
Because every effect is a pure, serializable description of work, you get predictable teardown sequences across complex dependency graphs without manual cleanup boilerplate.
A computation is initiated by an external event. During a computation:
- At most one event is processed at a time.
- External events are suspended.
- Effects may suspend.
- Effects may emit internal events.
- Internal events are processed immediately.
- Tasks are owned by the transducer.
- Tasks may be cancelled.
- A computation terminates when no internal event and no immediate effect remains.
- Responses are produced only when a request computation terminates.
- Getting started with SwiftUI
- Taming async tasks in SwiftUI views
- Correct by construction (FSM/MVI)
- Effect hosts and dependency env
- Bridging event-driven and imperative code
- Recipes
Apache License, Version 2.0