Skip to content

Repository files navigation

Transduce

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 This Library Is (and Isn't)

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 transduce functions 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)

Guardrails & Conventions

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.

Quick Start

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) }
            }
        }
    }
}

What is a Reactive Transducer?

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 transduce function 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 EffectView and other hosts like Observables or Actors.
  • Response is derived purely from State and Event.
  • Clear async semantics at the call site with post, send, and request.

Transduce is SwiftUI-first but not SwiftUI-only—the core model is entirely UI-agnostic.

Installation

.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")
    ]
)

Key ideas

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).

Package Modules

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+.

Environments (Env)

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.

Real-world Architecture Patterns

Transduce excels at solving difficult dependency management problems where standard singletons typically fail — for example, network requests racing against async database initialization.

1. The Initialization Pattern (Request Buffering)

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
        }
    }
}

2. Call-Site Owned Tasks & Complex Setups

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.

Core Principles

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.

Learn more

License

Apache License, Version 2.0

About

A small SwiftUI helper for Elm-style event handling with explicit side effects

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages