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
enumCounter:Transducer{structState:Equatable{varcount=0}enumEvent:Equatable{case increment, decrement }staticletinitialState:State=.init()staticfunc transduce(_ state:inoutState, event:Event)->Effect{switch event {case.increment: state.count +=1case.decrement: state.count -=1}return.none
}}structContentView:View{@Stateprivatevarstate=Counter.State()varbody:someView{EffectView(of:Counter.self, state: $state){ state, input inHStack{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

ConceptWhat it does
Transducer(inout State, Event) → Effect — the single place that mutates state and describes follow-up work.
EffectA declarative description of what to do next (.task, .cancel, .action, .sequence, .none). The runtime performs it; your code only describes intent.
HostOwns task lifetime and routes events (EffectView for SwiftUI, BaseRuntime as base class).
EnvImmutable 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

ModuleStatusPurpose
TransducePublic (v1.0)Effect runtime — transducer types, effect system, event dispatch, task lifecycle, non-Combine observation layer.
ExpectInternal onlyLightweight 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:

structDependencies{varapi:APIClient; varclock:AnyClock}enumMoviesFeature:Transducer{enumState{case idle, loading, loaded([Movie]), error(Error)}enumEvent{case loadRequested, moviesLoaded([Movie]), loadFailed(Error)}typealiasEnv=DependenciesstaticletinitialState:State=.idle
staticfunc transduce(_ state:inoutState, event:Event)->Effect{switch(state, event){case(.idle,.loadRequested):
state =.loading
return.task(id:"load"){ input, env inletmovies=tryawait 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 ───
enumDatabaseManager:Transducer{enumState{case loading, ready }enumEvent{case startMigration, didInit, fetchData }staticletinitialState:State=.loading
// Note: We use `task` internally to bootstrap the manager
staticfunc transduce(_ state:inoutState, 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] _, _ intryawaitperformHeavyCoreDataMigration()iflet i = input {tryawait 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