diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/EffectView.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/EffectView.xcscheme
new file mode 100644
index 0000000..bda2159
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/EffectView.xcscheme
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Documentation/ArchitecturalComparison.md b/Documentation/ArchitecturalComparison.md
index fd6190c..328701f 100644
--- a/Documentation/ArchitecturalComparison.md
+++ b/Documentation/ArchitecturalComparison.md
@@ -107,7 +107,7 @@ EffectView translates the Elm/GenServer model into idiomatic SwiftUI — using `
**State model:** Three kinds of state are structurally distinct:
- **Ephemeral state** (`ViewState`) — a plain value type owned by `@State`. Lives and dies with the view's identity. Nothing outside the view can read or write it.
-- **Shared state** — an `@Observable` object passed via `Env` or the SwiftUI environment. A view *observes a named slice* of shared state via `.observe(\.store, keyPath: \.count)` and receives changes as events into its own `update` loop. The view never writes shared state directly — it sends events to the store's own mutation API.
+- **Shared state** — an `@Observable` object passed via `Env` or the SwiftUI environment. A view *observes a specific slice* of shared state via `.observe(\.store, keyPath: \.count)` and receives changes as events into its own `update` loop. The view never writes shared state directly — it sends events to the store's own mutation API.
- **Persistent state** — always external. Effects read from or write to persistence, then translate results back into events. `update` never sees storage directly.
The read/write relationship with shared state is asymmetric by construction:
@@ -119,19 +119,19 @@ ViewState ──(user action)──▶ update ──▶ effect ──▶ store.s
**Effect model:** `update` returns an `Effect` value — a description, never an execution. The library executes it. `update` is synchronous, has no `async` annotation, and cannot perform work directly. The same event on the same state always produces the same `Effect` description.
-**Task lifecycle:** Tasks are created by returning `.task(name:)` from `update`. They are cancelled by returning `.cancel(name)` from `update`, or automatically when the view's identity is torn down via `.id(...)`. Both creation and cancellation are outputs of the transition function — they live alongside state mutations in the same `switch`, subject to the same compiler exhaustiveness checks.
+**Task lifecycle:** Tasks are created by returning `.run(id:)`, `.request(id:)`, or `.task(id:)` from `update`. They are cancelled by returning `.cancel(...)` from `update`, or automatically when the view's identity is torn down via `.id(...)`. Both creation and cancellation are outputs of the transition function — they live alongside state mutations in the same `switch`, subject to the same compiler exhaustiveness checks.
-A named task is automatically cancelled and replaced if `update` returns a new `.task` with the same name before the previous one finishes. This makes cancel-and-restart a one-liner with no explicit handle management:
+A task is automatically cancelled and replaced if `update` returns new work with the same identifier before the previous one finishes. This makes cancel-and-restart a one-liner with no explicit handle management:
```swift
// update:
case .queryChanged(let q):
state.query = q
- return .task(name: "search") { input, env in // cancels any prior "search" task
+ return .run(id: "search") { input, env in
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
let results = await env.search(q)
- await input.perform(.resultsLoaded(results))
+ try? input.post(.resultsLoaded(results))
}
```
@@ -139,22 +139,24 @@ case .queryChanged(let q):
| Method | Semantics | Use when |
|---|---|---|
-| `input(.event)` | Fire-and-forget. Enqueues on `@MainActor`, returns immediately. | Button handlers, `onChange`, observation fire-and-forget |
-| `await input.send(.event)` | Waits until `update` has processed the event. State is settled; effects are only *started*. | Caller needs to read resulting state, doesn't care about downstream work |
-| `await input.perform(.event)` | Suspends until the full effect chain — `update`, the returned effect, and any effects it triggers recursively — has settled. | Pull-to-refresh spinners, sequential task steps, observation loop backpressure |
+| `try input.post(.event)` | Fire-and-forget. Schedules on `@MainActor`, returns immediately. | Button handlers, `onChange`, observation fire-and-forget |
+| `try await input.send(.event)` | Waits until `update` has processed the event. State is settled; effects are only *started*. | Caller needs to read resulting state, doesn't care about downstream work |
+| `try await input.request(.event)` | Suspends until the full effect chain — `update`, the returned effect, and any effects it triggers recursively — has settled. | Pull-to-refresh spinners, sequential task steps, observation loop backpressure |
-`perform` is the mechanism that makes SwiftUI's `.refreshable` work naturally:
+`request` is the mechanism that makes SwiftUI's `.refreshable` work naturally:
```swift
.refreshable {
- await input.perform(.refresh) // spinner shown until the full load cycle completes
+ try? await input.request(.refresh)
+ // spinner shown until the full load cycle completes
}
```
And it provides natural backpressure in observation loops — the loop does not advance to wait for the next store change until the view has fully processed the current one:
```swift
-await input.perform(.storeChanged(newCount: count)) // next observation cycle waits here
+try? await input.request(.storeChanged(newCount: count))
+// next observation cycle waits here
```
No rate-limiting code, no semaphores. The dispatch semantic *is* the backpressure mechanism.
@@ -168,6 +170,28 @@ XCTAssertEqual(state.isLoading, true)
// inspect the returned Effect description if needed
```
+**Component API surface:** An EffectView-based component exposes exactly three concepts to the outside world:
+
+| Concept | Role |
+|---|---|
+| `Event` | What you can *send* to it |
+| `Output` | What you can *receive* from it (via `try await input.request(_:)`) |
+| `State` | What it *renders* (optionally exposed) |
+
+Everything else — running tasks, `Env`, intermediate async types, internal models — is private to the component. The `Output?` value is not the return value of an async operation; it is what the transition function decides to hand back after the machine has processed the effect chain. The async work inside a task is an implementation detail the caller never sees.
+
+`State` has a natural second layer of privacy: the `Binding` is held by a `private @State` in the enclosing SwiftUI view. Ancestor views see only what is rendered — they never hold the binding. In practice, the visible API of a component from the outside is just `Event` and `Output`:
+
+```
+Ancestor view │ SwiftUI view (owner) │ EffectView internals
+───────────────────────┼──────────────────────────┼─────────────────────────
+sees rendered UI only │ State (private @State) │ tasks (private)
+can send Event │ Event │ Env (private)
+awaits Output? │ Output │ intermediate types
+```
+
+This is a stronger encapsulation boundary than TCA's store, which is deliberately transparent: any code holding a `Store` reference can observe the entire state tree. EffectView components behave more like actors — they receive messages, produce typed responses, and keep everything else behind a wall.
+
---
## Summary
@@ -181,7 +205,7 @@ XCTAssertEqual(state.isLoading, true)
| **Task creation** | Anywhere | Middleware | `Effect.run` | Runtime | Spawn | `update` return value |
| **Task cancellation** | Manual handle | Dispatch cancel action | `CancelID` action | Runtime subscription diff | Process termination | `update` return value |
| **Task scope** | ViewModel lifetime | App lifetime | Store lifetime | Runtime | Process tree | View identity |
-| **Dispatch levels** | Synchronous call | Fire-and-forget | Fire-and-forget (+ async `send` inside effects) | Fire-and-forget | `call` (sync) / `cast` (async) | `enqueue` / `send` / `perform` |
+| **Dispatch levels** | Synchronous call | Fire-and-forget | Fire-and-forget (+ async `send` inside effects) | Fire-and-forget | `call` (sync) / `cast` (async) | `post` / `send` / `request` |
| **Test surface** | Full class construction | Reducer pure function | `TestStore` harness | Pure `update` function | Process message passing | Static pure function |
The common thread in the well-designed patterns (Elm, GenServer, TCA, EffectView) is the same: a single authoritative transition function that owns all state mutations and returns effect descriptions. The differences are in scope (global vs. local), dispatch semantics, task lifecycle management, and how much framework ceremony is required to express the pattern.
diff --git a/Documentation/BridgingEventDrivenAndImperative.md b/Documentation/BridgingEventDrivenAndImperative.md
index 460ab37..90d1959 100644
--- a/Documentation/BridgingEventDrivenAndImperative.md
+++ b/Documentation/BridgingEventDrivenAndImperative.md
@@ -19,7 +19,8 @@ spinner disappears before the data arrives:
```swift
.refreshable {
- send(.refresh) // returns instantly; spinner stops too early
+ try? input.post(.refresh)
+ // returns instantly; spinner stops too early
}
```
@@ -32,11 +33,12 @@ The same issue arises for any SwiftUI feature that awaits an async closure:
## The solution: `request(_:)`
`Input.request(_:)` suspends the caller until the entire resulting effect chain
-has settled and returns an optional `Output` value:
+has settled and returns an optional `Output` value, or throws if the runtime
+cannot accept or complete the request:
```swift
.refreshable {
- await input.request(.refresh)
+ try? await input.request(.refresh)
// resumes only when .refresh has been
// fully processed and the effect settled
}
@@ -77,7 +79,7 @@ consequence of that event.
```swift
.refreshable {
- await input.request(.refresh)
+ try? await input.request(.refresh)
}
```
@@ -92,7 +94,7 @@ dismissing:
```swift
Button("Save") {
Task {
- let saved = await input.request(.save)
+ let saved = try? await input.request(.save)
if saved != nil { dismiss() }
}
}
@@ -106,7 +108,7 @@ settled:
```swift
.task(id: appPhase) {
if appPhase == .active {
- await input.request(.resumeIfNeeded)
+ try? await input.request(.resumeIfNeeded)
}
}
```
@@ -117,7 +119,7 @@ settled:
or polling required:
```swift
-let result = await input.request(.load)
+let result = try await input.request(.load)
XCTAssertEqual(state.items.count, 3)
```
@@ -127,13 +129,13 @@ XCTAssertEqual(state.items.count, 3)
| Approach | Stays suspended? | Returns a value? | Always resumes? |
|---|---|---|---|
-| `send(.refresh)` | No | — | — |
+| `try? input.post(.refresh)` | No | — | — |
| `XCTestExpectation` / `Task.sleep` | Roughly | No | No |
| XState `waitFor(predicate)` | Yes | No | No |
| TCA `store.send(.refresh).finish()` | Yes | No | No |
| ImmutableData `dispatcher.dispatch` | No | — | — |
| Akka `actor ? message` | Yes | Yes (explicit reply) | No |
-| `input.request(.refresh)` | Yes | Yes (`Output?`) | Yes |
+| `try await input.request(.refresh)` | Yes | Yes (`Output?`) | Yes |
### TCA: `StoreTask.finish()`
@@ -182,7 +184,7 @@ As a result, bridging to `.refreshable` requires a workaround: a state flag
```swift
// ImmutableData — workaround required
.refreshable {
- dispatcher.dispatch(action: .refresh)
+ try? dispatcher.dispatch(action: .refresh)
// must poll or observe isRefreshing to know
// when to let the closure return
}
@@ -211,5 +213,5 @@ where they are.
The continuation-threading mechanism means there is no semantic difference
between "I fired this event and don't care about the result" (`send` /
-`enqueue`) and "I fired this event and need to know when it's done"
+`post`) and "I fired this event and need to know when it's done"
(`request`). The FSM is identical in both cases; only the call site differs.
diff --git a/Documentation/CorrectByConstruction.md b/Documentation/CorrectByConstruction.md
index 612c210..7802337 100644
--- a/Documentation/CorrectByConstruction.md
+++ b/Documentation/CorrectByConstruction.md
@@ -88,19 +88,22 @@ enum SearchEvent {
The update function is a `switch` over state and event:
```swift
-func update(state: inout SearchState, event: SearchEvent) -> Effect? {
+static func update(
+ _ state: inout SearchState,
+ event: SearchEvent
+) -> Effect? {
switch (state, event) {
case (_, .searchTapped(let query)):
state = .loading(query: query)
return .sequence([
.cancel("search"),
- .task(name: "search") { input, env in
+ .run(id: "search") { input, env in
do {
let movies = try await env.search(query: query)
- input.enqueue(.resultsReceived(movies))
+ try? input.post(.resultsReceived(movies))
} catch {
- input.enqueue(.requestFailed(error.localizedDescription))
+ try? input.post(.requestFailed(error.localizedDescription))
}
}
])
@@ -160,7 +163,7 @@ Feature: Movie search
// Scenario: User starts a search
case (_, .searchTapped(let query)):
state = .loading(query: query)
- return .task(name: "search") { ... }
+ return .run(id: "search") { ... }
// Scenario: Search returns results
case (.loading(let query), .resultsReceived(let movies)):
@@ -235,7 +238,7 @@ You can also derive a transition table from the test suite and verify it matches
The enum state model eliminates category 1 entirely: the Swift type system won't let you represent `isLoading == true && errorMessage != nil` if your states are an enum.
-Category 2 is handled by the update function being processed serially on `@MainActor`. Two events never execute concurrently. State is never observed mid-mutation. The "what if the user taps twice?" scenario is just two calls to `update` in sequence: the second `searchTapped` hits the `.sequence([.cancel("search"), .task(name: "search") { ... }])` branch and replaces the in-flight task cleanly.
+Category 2 is handled by the update function being processed serially on `@MainActor`. Two events never execute concurrently. State is never observed mid-mutation. The "what if the user taps twice?" scenario is just two calls to `update` in sequence: the second `searchTapped` hits the `.sequence([.cancel("search"), .run(id: "search") { ... }])` branch and replaces the in-flight task cleanly.
Concurrency exists — tasks genuinely run in the background — but concurrency never *touches* state directly. It only delivers events. The update function remains a simple, synchronous function.
@@ -264,7 +267,7 @@ The key MVI property is **unidirectional data flow**: state flows down into the
| State | Swift enum | Impossible states unrepresentable |
| Transitions | `update` function | Pure, synchronous, compiler-verified |
| Side effects | `Effect` return values | Declarative descriptions, not execution |
-| Async work | Task closures in `Effect` | Isolated, named, cancellable |
+| Async work | Task closures in `Effect` | Isolated, identified, cancellable |
| Rendering | SwiftUI `Content` closure | Reads state only, fires events only |
The update function does one thing: given a state and an event, decide what the next state is and what work to trigger. Because it is pure and synchronous, it is trivially testable, straightforwardly readable, and directly traceable to requirements. Concurrency is real, but it is confined to the edges — it delivers events, it doesn't own state.
diff --git a/Documentation/Recipes.md b/Documentation/Recipes.md
new file mode 100644
index 0000000..139c59b
--- /dev/null
+++ b/Documentation/Recipes.md
@@ -0,0 +1,117 @@
+# Recipes
+
+Short, practical snippets for common EffectView patterns.
+
+## Post an event from the view
+
+Use `post` when the view should trigger work and continue immediately.
+
+```swift
+Button("Retry") {
+ try? input.post(.retryTapped)
+}
+
+.onChange(of: query) {
+ try? input.post(.queryChanged($0))
+}
+```
+
+`post` is fire-and-forget. It schedules the event and returns immediately.
+
+## Wait until a flow has settled
+
+Use `request` when the caller should wait until the whole triggered flow is done.
+
+```swift
+List(state.items, id: \.id) { item in
+ Text(item.title)
+}
+.refreshable {
+ try? await input.request(.refresh)
+}
+```
+
+This is the right choice for `.refreshable`, because SwiftUI keeps the spinner visible while the request is still in flight.
+
+## Debounce or restart search automatically
+
+Give the task an identifier. Starting the same identifier again replaces the older work.
+
+```swift
+case .queryChanged(let query):
+ state.query = query
+ state.isLoading = true
+
+ return .run(id: "search") { input, env in
+ try? await Task.sleep(for: .milliseconds(300))
+ guard !Task.isCancelled else { return }
+
+ do {
+ let results = try await env.search(query)
+ try? input.post(.resultsLoaded(results))
+ } catch {
+ try? input.post(.searchFailed(error.localizedDescription))
+ }
+ }
+```
+
+That removes the need to store and cancel `Task` handles manually.
+
+## Cancel stale work before starting new work
+
+Use `sequence` when one step should happen before another.
+
+```swift
+case .refresh:
+ state.isRefreshing = true
+ return .sequence([
+ .cancel("load"),
+ .run(id: "refresh") { input, env in
+ do {
+ let items = try await env.loadItems()
+ try? input.post(.loaded(items))
+ } catch {
+ try? input.post(.loadFailed(error.localizedDescription))
+ }
+ }
+ ])
+```
+
+This is a concise way to say: stop the old work first, then start the replacement.
+
+## Mirror an external `@Observable` value into feature state
+
+Use `observe` when a feature should react to changes from an external store or model.
+
+```swift
+struct Env: Sendable {
+ var store: CounterStore
+}
+
+case .startObserving:
+ return .observe(\.store, keyPath: \.count) { input, count in
+ try? await input.request(.countChanged(count))
+ }
+
+case .countChanged(let count):
+ state.count = count
+ return nil
+```
+
+The observation task emits the initial value immediately, then sends updates as the observed value changes.
+
+## Keep logic easy to test
+
+Because the decision-making stays in `update`, you can test the feature by driving state and events directly.
+
+```swift
+var state = SearchFeature.State()
+
+let effect = SearchFeature.update(&state, event: .queryChanged("milk"))
+
+XCTAssertEqual(state.query, "milk")
+XCTAssertTrue(state.isLoading)
+XCTAssertNotNil(effect)
+```
+
+The test checks what changed immediately. If needed, separate tests can exercise the returned effect path.
\ No newline at end of file
diff --git a/Documentation/RuntimeDesign.md b/Documentation/RuntimeDesign.md
new file mode 100644
index 0000000..02a5c5f
--- /dev/null
+++ b/Documentation/RuntimeDesign.md
@@ -0,0 +1,345 @@
+# Runtime Design
+
+This note describes the runtime model behind `EffectView`.
+
+It is not an API tutorial. The goal is to make the runtime's invariants explicit, explain why the implementation looks the way it does, and preserve the design intent as the library evolves.
+
+In short, `EffectView` is an event-driven runtime built around a finite state machine model of computation. Events drive state transitions, effects describe operations to perform, and the runtime executes and manages those operations while preserving ordered state reduction.
+
+At a high level, effects come in two forms:
+
+- Actions are inline effect steps in the current computation cycle. Unlike tasks, they remain part of the current event chain even when they suspend.
+- tasks are managed asynchronous operations; they run outside the current reduction step, may be tracked by logical identifier, and can feed events back into the system later
+
+Two earlier articles describe adjacent concerns from the public API side:
+
+- [Taming async tasks in SwiftUI views](TamingAsyncTasksInSwiftUIViews.md) explains why the library needs runtime-managed effects instead of relying on SwiftUI's `.task` modifier.
+- [Using Env for dependency injection](UsingEnvForDependencyInjection.md) explains how dependencies are captured and forwarded into effects.
+
+This document focuses on the runtime itself: event processing, back pressure, cancellation, continuations, and the split between domain work and runtime control.
+
+---
+
+## The problem this runtime solves
+
+At the feature level, `EffectView` wants a simple contract:
+
+- state changes only in `update`
+- effects are declared, not performed inline
+- async work can send events back into the system
+- callers can choose how to dispatch an event: post it and return immediately, send it and wait for the current computation cycle, or request it and suspend until the terminal result is available
+
+That contract becomes significantly harder to uphold once async actions and runtime-managed tasks exist.
+
+In particular, the runtime must answer these questions:
+
+1. Can event processing be re-entered while an async action is suspended?
+2. What happens when a hard cancellation or fatal runtime error arrives while an event chain is mid-flight?
+3. Who owns a request continuation at each phase of execution?
+4. How should overlapping callers apply back pressure to the system?
+5. Which concerns belong to the transducer state machine, and which belong to the runtime itself?
+
+The current design answers those questions explicitly rather than implicitly.
+
+---
+
+## Design goals
+
+The runtime is intentionally designed around the following goals:
+
+1. Preserve a single ordered mutation path for domain state.
+2. Support async effects and async actions without pushing buffering logic into transducer state.
+3. Allow callers to use ordinary suspending functions as the back pressure mechanism.
+4. Permit immediate runtime interruption while a regular event chain is suspended.
+5. Keep the implementation small enough that the invariants are locally understandable.
+
+These goals are Swift-shaped. They rely on actor isolation, structured concurrency, typed continuations, and `Sendable` boundaries. The same design may not map directly to languages that lack those tools.
+
+---
+
+## The two-path model
+
+The runtime has two distinct execution paths:
+
+- `compute(...)` processes regular domain events.
+- `control(...)` processes runtime control events.
+
+This split is fundamental.
+
+### `compute(...)`
+
+`compute(...)` is the domain path. It:
+
+- reads and mutates transducer state
+- calls `update`
+- executes returned effects
+- may suspend while awaiting async actions
+- may transfer a request continuation into a managed task
+
+Because `compute(...)` is the path that can mutate state, it must not be re-entered.
+
+### `control(...)`
+
+`control(...)` is the runtime path. It:
+
+- does not call `update`
+- must not mutate transducer storage
+- may cancel the runtime or latch a system failure
+- may run while a regular `compute(...)` invocation is suspended
+
+This is not merely acceptable. Once async actions exist, it is the correct model. A hard-stop control path must be able to intercept a suspended event chain immediately. Otherwise shutdown would be delayed until the action returns, which would weaken runtime cancellation semantics substantially.
+
+---
+
+## Core invariants
+
+The runtime is designed around these invariants:
+
+1. `compute(...)` is never re-entered.
+2. `control(...)` may interleave with a suspended `compute(...)`.
+3. `control(...)` must never mutate transducer storage.
+4. All regular event entry points pass through the same gate before entering `compute(...)`.
+5. After every suspension point inside `compute(...)`, runtime cancellation must be re-checked before more work is committed.
+6. A thrown `compute(...)` does not consume the request continuation; the caller remains responsible for resuming or failing it.
+7. Shutdown and latched failure state remain centralized in one runtime authority.
+
+If future changes violate one of these rules, they should be treated as a design change, not as an incidental refactor.
+
+---
+
+## Why `compute(...)` is gated
+
+Without a gate, an awaited async action yields the system actor, allowing another task to enter the runtime and call `compute(...)` again. That would break the single ordered mutation model.
+
+The runtime therefore uses a gate in front of `compute(...)`.
+
+Conceptually, the gate means:
+
+- if no regular event chain is active, the caller enters immediately
+- if a regular event chain is already active, the next caller suspends until admission
+
+This is a strict back pressure model.
+
+The runtime does not primarily buffer events. Instead, callers wait.
+
+### Why not an event buffer?
+
+An event buffer is viable, but it encodes a different policy:
+
+- the runtime owns queued events
+- overflow policy becomes part of the design
+- buffering and scheduling concerns become more prominent than caller suspension
+
+The current design chooses the opposite center of gravity:
+
+- the caller owns its event until admitted
+- suspension is the back pressure mechanism
+- structured concurrency expresses the waiting relationship directly
+
+In practice, this lets call sites remain simple: they use suspending functions, and back pressure emerges from the language's existing async model rather than from a separate buffering abstraction.
+
+### Why the gate can stay simple
+
+The gate itself can stay small because it lives on the system actor. It does not need mutexes or cross-thread synchronization primitives. Its job is only to serialize admission to `compute(...)`.
+
+Implementation details may evolve, but conceptually the gate is a FIFO of waiting senders or continuations, not a mailbox of events.
+
+---
+
+## Event entry semantics
+
+The runtime exposes three relevant caller-facing modes:
+
+- fire-and-forget event submission
+- synchronous event submission
+- request-style submission with a result continuation
+
+These modes differ in how much of the event chain the caller waits for, but they all rely on the same underlying serialization rules for regular events.
+
+### Fire-and-forget
+
+A fire-and-forget call schedules work and returns immediately. The caller does not wait for `compute(...)` to run.
+
+This is intentionally the weakest back pressure mode. It is useful, but it is also the deliberate escape hatch: callers can create pending work without themselves awaiting admission.
+
+### Synchronous send
+
+A synchronous `send` means:
+
+- the caller waits for the event chain it started
+- the caller does not directly await unrelated managed tasks unless the chain reaches a request-style effect
+- the system guarantees that regular event reduction remains serialized
+
+The important nuance is that "synchronous" here is semantic, not literally non-suspending. If an async action runs inline, `send` may suspend while still preserving single-entry regular event reduction.
+
+### Request
+
+A request carries a continuation through the event chain until the chain terminates, transfers the continuation into a managed task, or throws out of `compute(...)`.
+
+This is the runtime's bridge between event-driven logic and ordinary async/await callers.
+
+---
+
+## Async actions
+
+Async actions exist to model a bounded awaited step that must remain logically inside the current event chain.
+
+They are intentionally different from managed tasks.
+
+An async action:
+
+- runs inline as part of the current `compute(...)` chain
+- does not create its own task identity
+- does not participate in task overlap policies like `subscribe` or `switchToLatest`
+- may suspend
+- must be followed by a cancellation re-check before its result is trusted
+
+This makes async actions suitable for short prerequisite steps such as:
+
+- actor bootstrap before later events are allowed to proceed
+- establishing an authorization token or capability handle
+- awaiting a bounded dependency precondition before the next event can be interpreted correctly
+
+Async actions are not the right tool for long-running background work, subscriptions, retry loops, or overlapping work that needs runtime-managed identity. Those belong to managed tasks.
+
+---
+
+## Post-suspension cancellation checks
+
+Because `control(...)` may intercept a suspended `compute(...)`, an awaited async action cannot simply resume and continue as if nothing happened.
+
+After an async suspension point, `compute(...)` must re-check runtime cancellation through the runtime's central cancellation state before it:
+
+- resumes a request continuation
+- feeds the returned event back into the loop
+- mutates more state indirectly through another call to `update`
+
+The important detail is that this re-check should use the runtime's central cancellation mechanism rather than ad-hoc boolean tests.
+
+That preserves the latched shutdown reason and keeps the thrown error consistent with the rest of the runtime's boundary behavior.
+
+---
+
+## Shutdown and failure authority
+
+The runtime needs one authoritative place for shutdown and latched failure state.
+
+In the current implementation, much of that responsibility lives in `TaskManager`. A future runtime `Context` could own that state more directly while still preserving the same semantic contract.
+
+Conceptually, this authority owns:
+
+- managed task tracking
+- overlap policy for logically identified tasks
+- task waiter sets
+- latched cancellation and shutdown state
+- the distinction between normal managed cancellation and fatal runtime failure
+
+This is why `checkCancellation()` is such a central primitive. It turns internal runtime state into a public execution rule: if the runtime is no longer accepting work, the current path must stop.
+
+Keeping this authority centralized prevents the runtime from drifting into multiple slightly different notions of cancellation.
+
+---
+
+## Continuation ownership
+
+Request continuations intentionally have asymmetric ownership.
+
+During regular synchronous computation, the continuation is owned by the current `compute(...)` frame.
+
+If the chain reaches a managed task, ownership transfers to `TaskManager`, which resumes the waiting caller when that task completes, fails, or is cancelled.
+
+If `compute(...)` throws, the continuation is not consumed by `compute(...)`. The caller that entered `compute(...)` remains responsible for resuming or failing it.
+
+This rule is especially important for interrupted async actions:
+
+- the async action resumes
+- `compute(...)` re-checks cancellation
+- `compute(...)` throws because the runtime was invalidated mid-flight
+- the outer caller maps or forwards that failure and resumes the continuation exactly once
+
+That ownership discipline avoids double-resume bugs and keeps failure propagation localized.
+
+---
+
+## Managed tasks and overlap semantics
+
+Managed tasks solve a different problem from async actions.
+
+They represent asynchronous work that should be tracked by logical identifier and governed by overlap policy.
+
+The runtime currently supports at least two overlap behaviors:
+
+- `switchToLatest`: cancel the current physical task instance, keep the waiter set, and move those waiters to the replacement task
+- `subscribe`: keep the running task and attach the new waiter to the existing logical work
+
+This model gives the runtime a principled answer to overlapping requests without requiring feature code to store task handles manually.
+
+It also means the runtime can express request/response style behavior without forcing every transducer to implement its own queue or subscription bookkeeping in domain state.
+
+---
+
+## Why `control(...)` is a promising extension point
+
+The `compute(...)` / `control(...)` split does more than enable interruption.
+
+It also creates a real control plane.
+
+Because `control(...)` is runtime-facing and storage-safe, it can host future runtime features without polluting the domain event model.
+
+Plausible extensions include:
+
+- diagnostics and runtime introspection
+- fault injection in tests
+- tracing and instrumentation
+- runtime lifecycle commands
+- reporting current gate or task-manager state
+
+For example, a diagnostic control event could print or export current runtime context, including task-manager state, without pretending that diagnostics are part of the feature's domain event vocabulary.
+
+This is one of the design's strongest architectural consequences: operational concerns get a dedicated channel with dedicated rules.
+
+---
+
+## Why this is a Swift-native design
+
+This runtime model leans heavily on Swift's specific features:
+
+- actor isolation provides serialization boundaries
+- structured concurrency expresses back pressure naturally as suspension
+- continuations bridge event-driven logic to async/await callers
+- `Sendable` strengthens boundary discipline
+- first-class closures make dependency injection through `Env` lightweight
+
+That combination makes it realistic to implement what is effectively an FSM effect actor without introducing a large framework or an elaborate supervisory architecture.
+
+Other languages may need different primitives, especially if they lack actor isolation or typed continuation-style suspension. In Swift, this design maps naturally onto the language rather than fighting it.
+
+---
+
+## Testable consequences
+
+The following behaviors should remain pinned down by tests:
+
+1. `compute(...)` is not re-entered while another regular event chain is active.
+2. A control event may cancel the runtime while `compute(...)` is suspended in an async action.
+3. After that interruption, the suspended `compute(...)` frame does not continue processing returned events.
+4. A request interrupted during an async action completes with the correct runtime failure semantics.
+5. Managed task overlap policies continue to preserve waiter ownership correctly.
+6. Control events never mutate transducer storage.
+
+These tests are not implementation details. They are executable statements of the design.
+
+---
+
+## Summary
+
+The runtime is intentionally built around four ideas:
+
+1. regular event reduction is serialized through gated `compute(...)`
+2. runtime control is separated into ungated `control(...)`
+3. caller suspension provides the primary back pressure mechanism
+4. `TaskManager` centralizes shutdown and task-lifecycle semantics
+
+This gives `EffectView` a runtime that stays small in code size while still supporting async actions, request/response bridging, runtime-managed tasks, immediate interruption, and future runtime control features.
+
+The design is intentional, not accidental.
\ No newline at end of file
diff --git a/Documentation/SwiftUIFirst.md b/Documentation/SwiftUIFirst.md
index 6056620..4e4c78d 100644
--- a/Documentation/SwiftUIFirst.md
+++ b/Documentation/SwiftUIFirst.md
@@ -34,10 +34,13 @@ A ViewModel in the iOS world is typically a class that holds `@Published` proper
With EffectView, state is a Swift value type (`struct` or `enum`) owned by the caller via `Binding`. There is no class, no `@Published`, no `objectWillChange`, and no `deinit` to worry about. State is just data.
-Logic lives in a pure function:
+Logic lives in the transducer's transition function:
```swift
-func update(state: inout State, event: Event) -> Effect?
+static func update(
+ _ state: inout State,
+ event: Event
+) -> Self.Effect?
```
This function is not an object. It has no stored properties, no lifecycle, and no hidden shared state. It is easier to read, easier to test, and easier to trace than a ViewModel — and it does more, because it explicitly models every side effect as a return value rather than as a fire-and-forget call inside an async method.
@@ -56,7 +59,7 @@ Dependencies are declared as structs of closures in the feature module. Concrete
### No Combine
-Combine was the bridge between async work and `@Published` properties on the main thread. Structured concurrency obsoletes most of that. EffectView's `Input` type handles dispatch from any isolation — `send` for synchronous `@MainActor` calls, `enqueue` for fire-and-forget from background tasks, and `perform` when you need to await acknowledgement.
+Combine was the bridge between async work and `@Published` properties on the main thread. Structured concurrency obsoletes most of that. EffectView's `Input` type handles dispatch from any isolation — `send` for synchronous `@MainActor` calls, `post` for fire-and-forget from background tasks, and `request` when you need to await acknowledgement.
---
@@ -64,7 +67,7 @@ Combine was the bridge between async work and `@Published` properties on the mai
| Concern | Solution | Article |
|---|---|---|
-| Async task management | Named, cancellable tasks via `Effect.task` | [Taming async tasks in SwiftUI views](TamingAsyncTasksInSwiftUIViews.md) |
+| Async task management | Identified, cancellable tasks via `Effect.run` | [Taming async tasks in SwiftUI views](TamingAsyncTasksInSwiftUIViews.md) |
| Correctness and logic | FSM update function, impossible states unrepresentable | [Correct by Construction](CorrectByConstruction.md) |
| Dependency injection | Struct of closures + SwiftUI environment + `EnvReader` | [Using Env for Dependency Injection](UsingEnvForDependencyInjection.md) |
@@ -78,10 +81,11 @@ Combine was the bridge between async work and `@Published` properties on the mai
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ EnvReader(\.myEnv) { env in │ │
-│ │ EffectView(state: $state, │ │
+│ │ EffectView(of: Logic.self, │ │
+│ │ state: $state, │ │
│ │ initialEnv: env, │ │
-│ │ update: Logic.update) { state, send │ │
-│ │ MyContent(state: state, send: send) │ │
+│ │ ) { state, input in │ │
+│ │ MyContent(state: state, send: input) │ │
│ │ } │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
diff --git a/Documentation/TamingAsyncTasksInSwiftUIViews.md b/Documentation/TamingAsyncTasksInSwiftUIViews.md
index aa84a39..f5170dc 100644
--- a/Documentation/TamingAsyncTasksInSwiftUIViews.md
+++ b/Documentation/TamingAsyncTasksInSwiftUIViews.md
@@ -13,11 +13,12 @@ This article walks through those walls one by one, and shows how `EffectView` ad
But event-driven systems have a structural limitation: **you cannot directly `await` a logical operation**. You can only fire an event and move on:
```swift
-input.send(.fetch)
-// ... that's it. No return value. No error. No completion signal.
+try input.post(.fetch)
+// ... that's it. The event is scheduled, but there is still
+// no completion signal for the logical operation.
```
-The result of the fetch arrives later, indirectly, as a state change triggered by a `.loaded` or `.loadFailed` event. To know when the operation is complete, you have to observe state — which is cumbersome every time you need to bridge between the event-driven world and a caller that expects `async`/`await` semantics.
+The result of the fetch arrives later, indirectly, as a state change triggered by a `.loaded` or `.loadFailed` event. If dispatch itself fails, you learn that immediately. But to know when the operation is complete, you still have to observe state — which is cumbersome every time you need to bridge between the event-driven world and a caller that expects `async`/`await` semantics.
This friction shows up acutely with SwiftUI's `.refreshable` modifier. It needs something to `await` — a suspension that holds the system refresh spinner until the work is genuinely done. An event-driven system has no natural answer for this. Sending `.refresh` returns immediately; the spinner would dismiss before the data has arrived.
@@ -28,19 +29,21 @@ The same problem appears anywhere a caller needs to know when an event's consequ
`Input` provides three methods that give you precise control over how much of the event chain you wait for:
```swift
-input(.loaded(items)) // fire-and-forget: schedules event, returns immediately
-await input.send(.loaded(items)) // wait until update() has run
-await input.perform(.loaded(items)) // wait until update() *and all resulting effects* have settled
+try input.post(.loaded(items)) // fire-and-forget: schedules event, returns immediately
+try await input.send(.loaded(items))
+// wait until update() has run
+try await input.request(.loaded(items))
+// wait until update() *and all resulting effects* settle
```
-`perform` is the full bridge. It threads a continuation through the entire effect chain — if `.loaded` returns another effect, and that effect eventually completes, `perform` resumes only after all of it has settled. The call site reads like ordinary async/await code while the FSM continues to own all state mutations:
+`request` is the full bridge. It threads a continuation through the entire effect chain — if `.loaded` returns another effect, and that effect eventually completes, `request` resumes only after all of it has settled. The call site reads like ordinary async/await code while the FSM continues to own all state mutations:
```swift
// In a .refreshable block — the spinner holds until the full load cycle is complete:
-await input.perform(.refresh)
+try? await input.request(.refresh)
// In a test — assert state only after the operation has fully settled:
-await input.perform(.load)
+try await input.request(.load)
#expect(state.items.count == 20)
```
@@ -91,9 +94,9 @@ Two `.task` modifiers on the same view run independently. If one depends on the
Stepping back, the requirements that fall out of real apps are:
1. Task lifetime is tied to the **view's logical identity**, not to individual renders.
-2. Tasks can be **cancelled by name** from any event — a button tap, a timeout, a competing task starting.
-3. A **dynamic number** of named tasks can run concurrently.
-4. Starting a new task with a name that's already running **automatically cancels the previous one** — no manual bookkeeping.
+2. Tasks can be **cancelled by identifier** from any event — a button tap, a timeout, a competing task starting.
+3. A **dynamic number** of identified tasks can run concurrently.
+4. Starting new work with an identifier that's already running **automatically cancels the previous one** — no manual bookkeeping.
5. Results feed back into the view through a **single, ordered mutation point** — no scattered `@State` writes racing each other.
6. The `refreshable` spinner stays visible until the **full effect chain completes** — not just until the first `await`.
@@ -106,41 +109,48 @@ Stepping back, the requirements that fall out of real apps are:
`EffectView` separates concerns cleanly:
- **`update`** — a pure function `(inout State, Event) -> Effect?`. All state mutations happen here. No async, no throwing, just a switch.
-- **`Effect`** — what the view asks the runtime to do next: start a named task, cancel a named task, fire a synchronous action chain, or a sequence of the above.
+- **`Effect`** — what the view asks the runtime to do next: start tracked work, cancel tracked work, fire a synchronous action chain, or a sequence of the above.
- **`Input`** — how async work sends events back into the update loop.
-Tasks are identified by name strings at runtime, owned by the `EffectView` for its identity lifetime, and cancelled automatically when the view disappears.
+Tasks are identified by logical identifiers at runtime, owned by the `EffectView` for its identity lifetime, and cancelled automatically when the view disappears.
### `refreshable` that actually waits
-`perform(_:)` suspends the caller until the full effect chain — including any task that runs and sends events back — has completed. This makes it a natural fit for `refreshable`:
+`request(_:)` suspends the caller until the full effect chain — including any task that runs and sends events back — has completed. This makes it a natural fit for `refreshable`:
```swift
List(state.items, id: \.self) { Text($0) }
.refreshable {
- await input.perform(.refresh) // spinner stays until .refresh effect finishes
+ try? await input.request(.refresh)
+ // spinner stays until .refresh settles
}
```
-`.refresh` is a plain event. The work is a named task returned from `update`:
+`.refresh` is a plain event. The work is a task returned from `update`:
```swift
case .refresh:
- return .task(name: "refresh") { input, env in
- let items = try await env.fetch()
- await input.perform(.loaded(items))
+ return .task(id: "refresh") { input, env in
+ do {
+ let items = try await env.fetch()
+ return try await input.request(.loaded(items))
+ } catch {
+ return try await input.request(
+ .loadFailed(error.localizedDescription)
+ )
+ }
}
```
The three dispatch strategies on `Input` differ in what they wait for:
-- **`input(.loaded(items))`** — calls `enqueue`, which schedules the event on the `@MainActor` and returns immediately. The task closure exits before `update` processes `.loaded(items)`, so the outer `perform(.refresh)` continuation resumes before the state is updated. The spinner disappears too early.
+- **`try input.post(.loaded(items))`** — schedules the event on the `@MainActor` and returns immediately. The task closure exits before `update` processes `.loaded(items)`, so the outer `request(.refresh)` continuation resumes before the state is updated. The spinner disappears too early.
-- **`await input.send(.loaded(items))`** — hops to the `@MainActor` and runs `update(.loaded(items))` synchronously before returning. The state is updated before the closure exits. However, `send` passes a `nil` continuation, so if `.loaded` itself returns an effect — another task, an action chain — that effect's completion is not awaited. The closure exits as soon as `update` returns, regardless of what the effect does next.
+- **`try await input.send(.loaded(items))`** — hops to the `@MainActor` and runs `update(.loaded(items))` synchronously before returning. The state is updated before the closure exits. However, `send` passes a `nil` continuation, so if `.loaded` itself returns an effect — another task, an action chain — that effect's completion is not awaited. The closure exits as soon as `update` returns, regardless of what the effect does next.
-- **`await input.perform(.loaded(items))`** — threads the outer continuation through the entire effect chain triggered by `.loaded(items)`. The closure only exits once `update` has run *and* any effect it returned has fully settled. This is the correct choice here: it handles the simple case identically to `send`, and correctly extends the wait if `.loaded` ever grows to return an effect of its own.
+- **`try await input.request(.loaded(items))`** — threads the outer continuation through the entire effect chain triggered by `.loaded(items)`. The closure only exits once `update` has run *and* any effect it returned has fully settled. This is the correct choice here: it handles the simple case identically to `send`, and correctly extends the wait if `.loaded` ever grows to return an effect of its own.
-The rule of thumb: use `perform` when the result needs to be complete before the caller resumes; use `enqueue` for fire-and-forget signals where ordering doesn't matter.
+The rule of thumb: use `request` when the result needs to be complete before the caller resumes; use `post` for fire-and-forget signals where ordering doesn't matter.
### Cancel on user intent
@@ -155,13 +165,19 @@ That's it. No stored `Task` handle, no flag, no `id:` dance.
### Dynamic number of tasks
-Because task names are runtime strings, you can start as many tasks as the data dictates and cancel any individual one:
+Because task identifiers can be created at runtime, you can start as many tasks as the data dictates and cancel any individual one:
```swift
case .startDownload(let id):
- return .task(name: "download-\(id)") { input, env in
- let data = try await env.download(id)
- input.enqueue(.downloaded(id, data))
+ return .run(id: "download-\(id)") { input, env in
+ do {
+ let data = try await env.download(id)
+ try? input.post(.downloaded(id, data))
+ } catch {
+ try? input.post(
+ .downloadFailed(id, error.localizedDescription)
+ )
+ }
}
case .cancelDownload(let id):
@@ -172,16 +188,16 @@ No ViewModel, no array of handles, no manual lifecycle.
### Automatic cancel-and-restart (debounce / live search)
-Starting a task whose name is already running cancels the previous run first. Debounce is just a `Task.sleep` inside the operation — the restart behaviour is free:
+Starting a task whose identifier is already running cancels the previous run first. Debounce is just a `Task.sleep` inside the operation — the restart behaviour is free:
```swift
case .queryChanged(let q):
state.query = q
- return .task(name: "search") { input, env in
+ return .run(id: "search") { input, env in
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
let results = await env.search(q)
- input.enqueue(.resultsLoaded(results))
+ try? input.post(.resultsLoaded(results))
}
```
diff --git a/Documentation/UsingEnvForDependencyInjection.md b/Documentation/UsingEnvForDependencyInjection.md
index 73c3060..ac9a749 100644
--- a/Documentation/UsingEnvForDependencyInjection.md
+++ b/Documentation/UsingEnvForDependencyInjection.md
@@ -64,10 +64,13 @@ struct MovieSearchEnv: Sendable {
Either layout works. The struct-of-structs pattern scales better when several features share a dependency group.
-The `update` function type annotation now becomes:
+The transducer's `update` requirement now becomes:
```swift
-(inout MovieSearchState, MovieSearchEvent) -> Effect?
+static func update(
+ _ state: inout MovieSearchState,
+ event: MovieSearchEvent
+) -> Self.Effect?
```
And inside a task, `env` is simply the injected value:
@@ -77,13 +80,13 @@ case (_, .searchTapped(let query)):
state = .loading(query: query)
return .sequence([
.cancel("search"),
- .task(name: "search") { input, env in
+ .run(id: "search") { input, env in
env.trackQuery(query)
do {
let movies = try await env.search(query)
- input.enqueue(.resultsReceived(movies))
+ try? input.post(.resultsReceived(movies))
} catch {
- input.enqueue(.requestFailed(error.localizedDescription))
+ try? input.post(.requestFailed(error.localizedDescription))
}
}
])
@@ -158,20 +161,20 @@ struct MovieSearchView: View {
var body: some View {
EnvReader(\.movieSearchEnv) { env in
EffectView(
+ of: MovieSearchLogic.self,
state: $state,
- initialEnv: env,
- update: MovieSearchLogic.update
- ) { state, send in
- MovieSearchContent(state: state, send: send)
+ initialEnv: env
+ ) { state, input in
+ MovieSearchContent(state: state, send: input)
}
}
}
}
```
-`EnvReader` is a thin wrapper around `@Environment`; it exists purely for ergonomics at the `EffectView` call site. The value it captures is passed to `initialEnv:`, and EffectView takes ownership from there — forwarding it to every `.task` and `.action` for the lifetime of the view.
+`EnvReader` is a thin wrapper around `@Environment`; it exists purely for ergonomics at the `EffectView` call site. The value it captures is passed to `initialEnv:`, and EffectView takes ownership from there — forwarding it to every `.run`, `.request`, and `.action` for the lifetime of the view.
-Note that `update` is referenced as a static function (`MovieSearchLogic.update`) rather than a closure literal. This is not required, but it keeps the view body free of logic and makes the update function easily findable and independently testable.
+Note that the transducer type (`MovieSearchLogic.self`) is passed directly rather than constructing an inline closure. This keeps the view body free of logic and makes the transition function easily findable and independently testable.
---
@@ -191,7 +194,7 @@ struct MovieSearchTransitionTests {
@Test func searchTappedTransitionsToLoading() {
var state = MovieSearchState.idle
- let effect = MovieSearchLogic.update(state: &state, event: .searchTapped(query: "inception"))
+ let effect = MovieSearchLogic.update(&state, event: .searchTapped(query: "inception"))
#expect(state == .loading(query: "inception"))
#expect(effect != nil)
@@ -201,7 +204,7 @@ struct MovieSearchTransitionTests {
var state = MovieSearchState.loading(query: "inception")
let movies = try await testEnv.search("inception")
- _ = MovieSearchLogic.update(state: &state, event: .resultsReceived(movies))
+ _ = MovieSearchLogic.update(&state, event: .resultsReceived(movies))
#expect(state == .loaded(query: "inception", results: movies))
}
diff --git a/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj b/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj
index c769731..9513626 100644
--- a/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj
+++ b/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.pbxproj
@@ -414,7 +414,6 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
- OTHER_SWIFT_FLAGS = "-enable-upcoming-feature InferSendableFromCaptures";
PRODUCT_BUNDLE_IDENTIFIER = com.andreas.grosam.home.SimpleEffectView;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -447,7 +446,6 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
- OTHER_SWIFT_FLAGS = "-enable-upcoming-feature InferSendableFromCaptures";
PRODUCT_BUNDLE_IDENTIFIER = com.andreas.grosam.home.SimpleEffectView;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 0000000..aceb888
--- /dev/null
+++ b/Examples/EffectViewExample/EffectViewExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,15 @@
+{
+ "originHash" : "b971c143a10f44d540b259eca77415d2cc7af5727022d1fbfa7ed14ee8bb1f80",
+ "pins" : [
+ {
+ "identity" : "swift-mutex",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swhitty/swift-mutex.git",
+ "state" : {
+ "revision" : "1770152df756b54c28ef1787df1e957d93cc62d5",
+ "version" : "0.0.6"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/Examples/EffectViewExample/EffectViewExample/App.swift b/Examples/EffectViewExample/EffectViewExample/App.swift
index 3747db9..ec38b9a 100644
--- a/Examples/EffectViewExample/EffectViewExample/App.swift
+++ b/Examples/EffectViewExample/EffectViewExample/App.swift
@@ -6,12 +6,12 @@ struct EffectViewExampleApp: App {
var body: some Scene {
WindowGroup {
TabView {
- Counter.ContentView()
+ Counter.Views.ContentView()
.tabItem {
Label("Counter", systemImage: "plus.forwardslash.minus")
}
- Movies.ContentView()
+ Movies.Views.ContentView()
.tabItem {
Label("Movies", systemImage: "film")
}
diff --git a/Examples/EffectViewExample/EffectViewExample/Counter.swift b/Examples/EffectViewExample/EffectViewExample/Counter.swift
index c957f9d..8be6ec6 100644
--- a/Examples/EffectViewExample/EffectViewExample/Counter.swift
+++ b/Examples/EffectViewExample/EffectViewExample/Counter.swift
@@ -2,17 +2,64 @@ import SwiftUI
import Foundation
import EffectView
-public enum Counter {}
+enum Counter {
+ enum Views {}
+ enum Transducer {}
+}
// MARK: - Environment
-
extension EnvironmentValues {
- @Entry var counterViewEnv: Counter.CounterView.Env = .init()
+ @Entry var counterViewEnv: Counter.Transducer.Env = .init()
}
-// MARK: - Views
+// MARK: - Transducer
+extension Counter.Transducer: EffectView::Transducer {
+
+ struct State {
+ var counter = 0
+ init() {
+ self.counter = 0
+ }
+ }
+
+ enum Event {
+ case start
+ case tick
+ case stop
+ }
+
+ struct Env: Identifiable {
+ let id: UUID = .init()
+ init() {}
+ }
+
+ static func update(
+ _ state: inout State,
+ event: Event
+ ) -> Self.Effect? {
+ switch event {
+ case .start:
+ state.counter = 0
+ return run(id: "Counter") { input, env in
+ while true {
+ do {
+ try await Task.sleep(nanoseconds: 1_000_000_000) // 1 sec
+ print("tick")
+ input(.tick)
+ } catch {} // most likeley, the counter task has been cancelled; ignore it.
+ }
+ }
+ case .tick:
+ state.counter += 1; return nil
+ case .stop:
+ return cancel("Counter")
+ }
+ }
+
+}
-extension Counter {
+// MARK: - Views
+extension Counter.Views {
struct ContentView: View {
var body: some View {
@@ -23,61 +70,18 @@ extension Counter {
}
struct CounterView: View {
-
- struct ViewState {
- var counter = 0
- init() {
- self.counter = 0
- }
- }
-
- enum Event {
- case start
- case tick
- case stop
- }
-
- struct Env: Identifiable {
- let id: UUID = .init()
- init() {}
- }
+ typealias Transducer = Counter.Transducer
+ typealias Env = Transducer.Env
+ typealias ViewState = Transducer.State
@State private var state: ViewState = .init()
-
let env: Env
- @MainActor
- private static func update(
- state: inout ViewState,
- event: Event
- ) -> Effect? {
- switch event {
- case .start:
- state.counter = 0
- return .run(name: "Counter") { input, env in
- while true {
- do {
- try await Task.sleep(nanoseconds: 1_000_000_000) // 1 sec
- print("tick")
- input(.tick)
- } catch {
- // most likeley, the counter task has been cancelled; ignore it.
- }
- }
- }
- case .tick:
- state.counter += 1
- return nil
- case .stop:
- return .cancel("Counter")
- }
- }
-
var body: some View {
EffectView(
+ of: Transducer.self,
state: $state,
initialEnv: env,
- update: Self.update
) { state, send in
VStack {
Text("\(state.counter)")
@@ -91,8 +95,6 @@ extension Counter {
}
}
-#if false
#Preview {
- Counter.ContentView()
+ Counter.Views.ContentView()
}
-#endif
diff --git a/Examples/EffectViewExample/EffectViewExample/Movies.swift b/Examples/EffectViewExample/EffectViewExample/Movies.swift
index 32c8766..b2b6957 100644
--- a/Examples/EffectViewExample/EffectViewExample/Movies.swift
+++ b/Examples/EffectViewExample/EffectViewExample/Movies.swift
@@ -2,14 +2,17 @@ import SwiftUI
import EffectView
import Foundation
-public enum Movies {}
+enum Movies {
+ enum Views {}
+ enum Transducer {}
+}
// MARK: - Model
extension Movies {
- public struct Movie: Equatable, Identifiable {
- public let id: UUID
- public let title: String
+ struct Movie: Equatable, Identifiable {
+ let id: UUID
+ let title: String
}
}
@@ -17,21 +20,18 @@ extension Movies {
extension Movies {
- public struct MovieFetch: Sendable {
- public var fetch: @Sendable () async throws -> [Movie]
+ struct MovieFetch: Sendable {
+ var fetch: @Sendable () async throws -> [Movie]
- public func callAsFunction() async throws -> [Movie] {
+ func callAsFunction() async throws -> [Movie] {
try await fetch()
}
}
- public struct Env: Sendable {
- public var movieFetch: Movies.MovieFetch
- }
}
extension EnvironmentValues {
- @Entry public var movieListViewEnv: Movies.Env = .init(
+ @Entry var movieListViewEnv: Movies.Transducer.Env = .init(
movieFetch: .init(fetch: {
try await Task.sleep(nanoseconds: 2_000_000_000)
return [
@@ -45,72 +45,12 @@ extension EnvironmentValues {
}
-// MARK: - Views
-
-extension Movies {
-
- public struct ContentView: View {
- public var body: some View {
- EnvReader(\.movieListViewEnv) { env in
- Movies.MovieListView(env: env)
- }
- }
- }
-
- struct MovieListView: View {
- let env: Env
-
- @State private var state = ViewState()
-
- var body: some View {
- EffectView(state: $state, initialEvent: .load, initialEnv: env, update: Self.update) { state, input in
- ZStack {
- switch state.content {
- case .empty:
- if #available(iOS 17.0, *) {
- ContentUnavailableView("No Movies", systemImage: "film")
- } else {
- VStack(spacing: 12) {
- Image(systemName: "film")
- .font(.system(size: 48))
- .foregroundColor(.secondary)
- Text("No Movies")
- .font(.headline)
- .foregroundColor(.secondary)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- }
- case .content(let movies):
- List(movies, rowContent: MovieRow.init)
- .refreshable {
- await input.request(.refresh)
- }
- }
-
- if state.isLoading {
- ProgressView()
- }
- }
- .alert(
- "Error",
- isPresented: .constant(state.error != nil),
- presenting: state.error
- ) { _ in
- Button("OK") { input.send(.dismiss) }
- } message: { error in
- Text(error.localizedDescription)
- }
- }
- }
- }
-
-}
-
-extension Movies.MovieListView {
+// MARK: - Transducer
+extension Movies.Transducer: Transducer {
typealias Movie = Movies.Movie
- struct ViewState {
+ struct State {
var mode: Mode
var content: Content<[Movie]>
@@ -129,7 +69,7 @@ extension Movies.MovieListView {
var error: Error? {
if case .failed(let error) = mode { return error }
return nil
- }
+ }
var isLoading: Bool {
switch mode {
@@ -144,7 +84,7 @@ extension Movies.MovieListView {
}
}
- enum Event {
+ public enum Event {
case load
case refresh
case loaded([Movie])
@@ -152,9 +92,12 @@ extension Movies.MovieListView {
case cancel
case dismiss
}
-
- @MainActor
- static func update(state: inout ViewState, event: Event) -> Effect? {
+
+ struct Env: Sendable {
+ public var movieFetch: Movies.MovieFetch
+ }
+
+ static func update(_ state: inout State, event: Event) -> Effect? {
switch event {
case .load:
// Guard against refresh: can only race with programmatic load triggers
@@ -162,12 +105,12 @@ extension Movies.MovieListView {
guard !state.isRefreshing else { return nil }
guard !state.isLoading else { return nil }
state.mode = .loading
- return .loadMovies()
+ return loadMovies()
case .refresh:
// Always supersedes a pending load; named task cancels any prior refresh.
state.mode = .refreshing
- return .sequence([.cancel("load"), .refreshMovies()])
+ return sequence([cancel("load"), refreshMovies()])
case .loaded(let movies):
state.mode = .idle
@@ -180,31 +123,17 @@ extension Movies.MovieListView {
case .cancel:
state.mode = .idle
- return .cancel("load")
+ return cancel("load")
case .dismiss:
state.mode = .idle
return nil
}
}
-
-}
-
-extension Movies.MovieListView {
- struct MovieRow: View {
- let movie: Movie
-
- var body: some View {
- Text(movie.title)
- }
- }
-}
-
-// MARK: - Custom Effects
-
-extension Effect where Event == Movies.MovieListView.Event, Env == Movies.Env {
- static func loadMovies() -> Self {
- .run(name: "load") { input, env in
+
+
+ static func loadMovies() -> Effect {
+ run(id: "load") { input, env in
do {
let movies = try await env.movieFetch()
input(.loaded(movies))
@@ -214,9 +143,9 @@ extension Effect where Event == Movies.MovieListView.Event, Env == Movies.Env {
}
}
- static func refreshMovies() -> Self {
+ static func refreshMovies() -> Effect {
// Note: a refresh action
- .run(name: "refresh") { input, env in
+ run(id: "refresh") { input, env in
do {
let movies = try await env.movieFetch()
input(.loaded(movies))
@@ -227,11 +156,92 @@ extension Effect where Event == Movies.MovieListView.Event, Env == Movies.Env {
}
}
+// MARK: - Views
+extension Movies.Views {
+
+ struct ContentView: View {
+ var body: some View {
+ EnvReader(\.movieListViewEnv) { env in
+ MovieListView(env: env)
+ }
+ }
+ }
+
+ struct MovieListView: View {
+ typealias Transducer = Movies.Transducer
+ typealias Env = Transducer.Env
+ typealias ViewState = Transducer.State
+
+ let env: Env
+
+ @State private var state = ViewState()
+
+ var body: some View {
+ EffectView(
+ of: Transducer.self,
+ state: $state,
+ initialEvent: .load,
+ initialEnv: env
+ ) { state, input in
+ ZStack {
+ switch state.content {
+ case .empty:
+ if #available(iOS 17.0, *) {
+ ContentUnavailableView("No Movies", systemImage: "film")
+ } else {
+ VStack(spacing: 12) {
+ Image(systemName: "film")
+ .font(.system(size: 48))
+ .foregroundColor(.secondary)
+ Text("No Movies")
+ .font(.headline)
+ .foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ case .content(let movies):
+ List(movies, rowContent: MovieRow.init)
+ .refreshable {
+ try? await input.request(.refresh)
+ }
+ }
+
+ if state.isLoading {
+ ProgressView()
+ }
+ }
+ .alert(
+ "Error",
+ isPresented: .constant(state.error != nil),
+ presenting: state.error
+ ) { _ in
+ Button("OK") { input(.dismiss) }
+ } message: { error in
+ Text(error.localizedDescription)
+ }
+ }
+ }
+ }
+
+}
+
+extension Movies.Views.MovieListView {
+
+ typealias Movie = Movies.Movie
+
+ struct MovieRow: View {
+ let movie: Movie
+
+ var body: some View {
+ Text(movie.title)
+ }
+ }
+}
+
// MARK: - Previews
#Preview {
EnvReader(\.movieListViewEnv) { env in
- Movies.MovieListView(env: env)
+ Movies.Views.MovieListView(env: env)
}
}
-
diff --git a/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift b/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift
index bf53699..b17e1e8 100644
--- a/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift
+++ b/Examples/EffectViewExample/EffectViewExample/RemoteCounter.swift
@@ -3,8 +3,9 @@ import Foundation
import EffectView
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
-public enum RemoteCounter {
- public enum Views {}
+enum RemoteCounter {
+ enum Views {}
+ enum Transducer {}
}
// MARK: - Remote Store
@@ -18,6 +19,10 @@ extension RemoteCounter {
/// does not publish a stream.
@Observable @MainActor
final class CounterStore: Sendable {
+
+ static let shared: CounterStore = .init()
+
+ private init() {}
enum Event { case increment, decrement, reset }
@@ -27,97 +32,113 @@ extension RemoteCounter {
switch event {
case .increment: count += 1
case .decrement: count -= 1
- case .reset: count = 0
+ case .reset: count = 0
}
}
}
}
-// MARK: - Views
+// MARK: - Environment
+@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
+extension EnvironmentValues {
+ @Entry var remoteCounterEnv: RemoteCounter.Transducer.Env = .init(store: .shared)
+}
+// MARK: - Transducer
@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
-extension RemoteCounter.Views {
+extension RemoteCounter.Transducer: Transducer {
+
+ struct State {
+ var count: Int = 0
+ var lastDelta: Int = 0
+ }
- struct ContentView: View {
+ enum Event {
+ case start
+ case storeChanged(newCount: Int)
+ case incrementTapped
+ case decrementTapped
+ case resetTapped
+ }
- /// The store lives here — single instance for this subtree.
- @State private var store = RemoteCounter.CounterStore()
+ struct Env: Identifiable {
+ public let id: UUID = .init()
+ let store: RemoteCounter.CounterStore
+ }
+
+ static func update(
+ _ state: inout State,
+ event: Event
+ ) -> Effect? {
+ switch event {
+
+ case .start:
+ return observe(
+ \.store, keyPath: \.count,
+ id: "observe-store-count"
+ ) { input, value in
+ print("observation-handler store.count: ", value)
+ try? await input.request(.storeChanged(newCount: value))
+ }
- var body: some View {
- CounterView(env: .init(store: store))
+ case .storeChanged(let newCount):
+ // The only path that writes the mirrored value.
+ print("received event: \(event), state: \(state)")
+
+ state.lastDelta = newCount - state.count
+ state.count = newCount
+ return nil
+
+ case .incrementTapped:
+ print("incrementTapped")
+ return run { input, env in
+ await env.store.send(.increment)
+ }
+
+ case .decrementTapped:
+ print("decrementTapped")
+ return run { _, env in
+ await env.store.send(.decrement)
+ }
+
+ case .resetTapped:
+ print("resetTapped")
+ return run { _, env in
+ await env.store.send(.reset)
+ }
}
}
+}
- struct CounterView: View {
+// MARK: - Views
+@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
+extension RemoteCounter.Views {
+
+ typealias Transducer = RemoteCounter.Transducer
+ typealias ViewState = Transducer.State
+ typealias Env = Transducer.Env
- struct ViewState {
- var count: Int = 0
- var lastDelta: Int = 0
- }
+ struct ContentView: View {
- enum Event {
- case start
- case storeChanged(newCount: Int)
- case incrementTapped
- case decrementTapped
- case resetTapped
+ var body: some View {
+ EnvReader(\.remoteCounterEnv) { env in
+ CounterView(env: env)
+ }
}
+ }
- struct Env: Identifiable {
- let id: UUID = .init()
- let store: RemoteCounter.CounterStore
- }
+ struct CounterView: View {
@State private var state = ViewState()
let env: Env
- @MainActor
- static func update(
- _ state: inout ViewState,
- event: Event
- ) -> Effect? {
- switch event {
-
- case .start:
- return .observe(
- \.store, keyPath: \.count,
- name: "observe-store-count"
- ) { @MainActor input, value in
- print("observe-store-count: ", value)
- await input.request(.storeChanged(newCount: value))
- }
-
- case .storeChanged(let newCount):
- // The only path that writes the mirrored value.
- print("received event: \(event)")
-
- state.lastDelta = newCount - state.count
- state.count = newCount
- return nil
-
- case .incrementTapped:
- return .run { _, env in
- await env.store.send(.increment)
- }
-
- case .decrementTapped:
- return .run { _, env in
- await env.store.send(.decrement)
- }
-
- case .resetTapped:
- return .run { _, env in
- await env.store.send(.reset)
- }
- }
- }
var body: some View {
EffectView(
+ of: Transducer.self,
state: $state,
initialEvent: .start,
- initialEnv: env,
- update: Self.update(_:event:)
+ initialEnv: env
) { state, send in
VStack(spacing: 20) {
Text("\(state.count)")
@@ -137,7 +158,7 @@ extension RemoteCounter.Views {
private func deltaLabel(_ delta: Int) -> String {
switch delta {
- case 0: return " "
+ case 0: return "0"
case 1...: return "+\(delta)"
default: return "\(delta)"
}
@@ -146,7 +167,6 @@ extension RemoteCounter.Views {
}
// MARK: - Previews
-
#Preview {
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
RemoteCounter.Views.ContentView()
diff --git a/Package.swift b/Package.swift
index 4a1b359..f6f3e20 100644
--- a/Package.swift
+++ b/Package.swift
@@ -27,8 +27,10 @@ let package = Package(
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "EffectView",
+ dependencies: [
+ .product(name: "Mutex", package: "swift-mutex"),
+ ],
swiftSettings: [
- .enableUpcomingFeature("InferSendableFromCaptures"),
]
),
.testTarget(
@@ -38,7 +40,6 @@ let package = Package(
.product(name: "Mutex", package: "swift-mutex"),
],
swiftSettings: [
- .enableUpcomingFeature("InferSendableFromCaptures"),
]
),
],
diff --git a/README.md b/README.md
index fc93fc7..d3434c0 100644
--- a/README.md
+++ b/README.md
@@ -3,429 +3,151 @@
[](https://swiftpackageindex.com/couchdeveloper/EffectView)
[](https://swiftpackageindex.com/couchdeveloper/EffectView)
-A concrete SwiftUI pattern for state, events, and async effects — without an `@Observable` class, without scattered ad-hoc methods, favouring an event-driven, MVI-style design.
+EffectView is for SwiftUI developers who are tired of ViewModels that keep absorbing async methods, loading flags, `Task` handles, cancellation logic, and UI glue.
-- Single mutation point via `update`.
-- Explicit effects (`task`, `action`, `cancel`).
-- Optional dependency environment captured for the view lifetime.
+It gives your view one event-driven place where state changes are decided.
-## The problem with the conventional approach
+## The problem
-In a typical SwiftUI view backed by an `@Observable` ViewModel, state is mutated from many places — `onAppear`, button handlers, async task completions, timers. As the view grows:
+Most ViewModels start small and end up like this:
-- Two tasks can race to update the same property.
-- An `isLoading` flag gets set to `false` before a second request finishes.
-- A cancelled task still calls back and overwrites fresh state.
-- Testing requires constructing the whole ViewModel and observing side effects.
+- button handlers mutate state
+- async callbacks mutate state
+- refresh and search race each other
+- old work finishes late and overwrites fresh UI
+- tests have to drive a whole reference type instead of one transition function
-None of these are bugs you wrote on purpose. They're structural: there's no single, authoritative place that says "given this state and this event, here is the new state".
+The issue is usually not the architecture name. The issue is that state changes are spread across too many places.
-EffectView gives you that place.
+## The solution
-## What you get
+With EffectView, you move a feature's logic into a small stand-alone enum that declares `State`, `Event`, and one `update` function.
-- **One transition function owns all state changes.** `update` takes the current state and an event, and returns new state plus an optional effect — no `async`, no network calls inside it, just logic. The same event on the same state always produces the same outcome. Nothing else in the view can mutate state.
-- **Finite state machine rigour, without the ceremony.** All transitions live in one exhaustive `switch` over your `Event` enum. The compiler tells you when you've missed a case. No hidden paths, no forgotten edge cases.
-- **Async work is explicit and named.** Nothing runs unless `update` returned an `Effect`. Tasks are tracked by name, automatically cancelled when the view disappears, and replaced if re-issued.
-- **Test the entire view logic without a simulator.** Because `update` is a transition function with no async or network calls inside it, you can drive state, events, and async effects from a plain XCTest — no SwiftUI, no `@MainActor`, no mocking framework.
+`update` is a plain synchronous function: it receives the current state and an event, changes state, and decides what should happen next. It does not call services, start tasks, or cause side effects itself.
-
-## How it maps to patterns you know
-
-If you've used **VIPER**, think of `update` as the Presenter and Interactor collapsed into a single transition function. Events are inputs from the View; effects are the work the Interactor would kick off. The key difference: nothing executes inside `update` — it only *describes* what should happen. The library executes it.
-
-If you use **MVVM with `@Observable`**, `ViewState` replaces your ViewModel's published properties, and `Event` replaces your ViewModel's public methods. The mental shift is that instead of calling `viewModel.loadMovies()` imperatively, you send an event and `update` decides what effect to run.
-
-## Installation
-
-Add the package to your Swift Package Manager dependencies:
+If more work is needed, `update` returns an effect: just a function, possibly async, that the runtime executes one step later and where those side effects happen. That split keeps the logic easy to read and easy to test.
```swift
-// Package.swift
-.package(url: "https://github.com/couchdeveloper/EffectView.git", from: "0.1.0")
-```
-
-Then add `EffectView` to your target dependencies.
-
-## Usage
+import EffectView
+import SwiftUI
-1. **Define `State` and `Event`.** `State` is a plain value type holding everything the view needs to render. `Event` is an enum of all user actions and system notifications that can change state.
-
-2. **Define the transition function `update`.** A `static` function that takes the current state and an event, mutates state in place, and optionally returns an `Effect` to run or cancel. No async, no throwing — just a switch.
-
-3. **Render and send.** The `EffectView` content closure receives the current state and a `send` function. Render state, and call `send` for user actions.
-
-4. **Design service functions.** Long-running or async work lives in `.task` effects. These receive an `input` parameter which can be used to dispatch events back to the update loop as work progresses or completes. The example below where a `tick` event is sent back via input: `input(.tick)`:
-
-```swift
-struct CounterView: View {
- struct ViewState { var counter = 0 }
- enum Event { case start, tick, stop }
-
- @State private var state = ViewState()
-
- private static func update(
- state: inout ViewState,
- event: Event
- ) -> Effect? {
- switch event {
- case .start:
- state.counter = 0
- return .task(name: "Counter") { input, env in
- while true {
- do {
- try await Task.sleep(for: .seconds(1))
- input(.tick)
- } catch {
- // ignore cancellation
- }
- }
- }
- case .tick:
- state.counter += 1
- return nil
- case .stop:
- return .cancel("Counter")
- }
- }
-
- var body: some View {
- EffectView(state: $state, update: Self.update) { state, send in
- VStack {
- Text("\(state.counter)")
- Button("Start") { send(.start) }
- Button("Stop") { send(.stop) }
- }
- }
+enum SearchFeature: Transducer {
+ struct State {
+ var query = ""
+ var isLoading = false
+ var results: [String] = []
+ var errorMessage: String?
}
-}
-```
-
-## What would take 20 lines in a ViewModel takes 5 here
-
-Live search with automatic cancel-on-type — a task named `"search"` is automatically cancelled and restarted every time the query changes:
-
-```swift
-// update:
-case .queryChanged(let q):
- state.query = q
- return .task(name: "search") { input, env in
- try? await Task.sleep(for: .milliseconds(300))
- guard !Task.isCancelled else { return }
- let results = await env.search(q)
- await input.perform(.resultsLoaded(results))
- }
-```
-
-No manual `Task` handles. No `debounce` publisher chain. No flag to reset.
-## Behavior notes
-
-- `update` is captured once when the view appears.
-- The lifetime of any running task is controlled by the `EffectView`. All tasks are automatically cancelled when the view's identity ceases to exist. A task can also be cancelled earlier by returning `.cancel(name)` from `update`.
-
-## Effect
-
-The return type of `update`. Controls what happens after a state mutation.
-
-| Case | Purpose |
-|---|---|
-| `.task(name:priority:operation:)` | Starts an async operation. Named tasks are automatically cancelled and replaced if re-issued. |
-| `.action(action:)` | Synchronous step; the returned `Event?` is processed immediately in the same run loop. See warning below. |
-| `.cancel(name)` | Cancels a running named task. |
-
-> **Warning — `.action` cycles block the main thread.**
-> Because `.action` chains are unwound synchronously on the `@MainActor`, a cycle in your `update` function — e.g. `.ping` → `.action { .pong }` → `.action { .ping }` → … — will loop forever and hang the app. Keep action chains finite and acyclic. If you need iterative or potentially unbounded work, use a `.task` instead, where each iteration suspends and yields control back to the system.
-
-Returning `nil` means no effect — state was mutated but no async work is needed.
-
-### Custom effects
-
-For readability, effects can be declared as static factory methods on `Effect` constrained to the view's `Event` and `Env` types. This keeps `update` free of construction details and makes effects reusable across multiple cases.
-
-```swift
-extension Effect where Event == MyView.Event, Env == MyView.Env {
- static func loadItems() -> Self {
- .task(name: "load") { input, env in
- do {
- let items = try await env.fetch()
- await input.perform(.loaded(items))
- } catch {
- await input.perform(.loadFailed(error))
- }
- }
+ enum Event {
+ case queryChanged(String)
+ case searchResponse([String])
+ case searchFailed(String)
}
-}
-```
-
-`update` can then return `.loadItems()` instead of spelling out the full task inline.
-
-
-## Env and View identity
-
-If you pass `initialEnv`, it is captured once when the view appears. This is intentional — swapping dependencies mid-flight can cause subtle bugs where a running task started with one implementation finishes against another.
-
-The environment value is passed as an argument to the effect's operation and action closure and can carry dependencies or configuration values, see custom effect example above.
-To apply new dependencies, recreate the view identity with `.id(...)`.
-
-## Dependency injection
-
-Declare dependencies as a struct in the view layer. This keeps the interface close to the consumer and makes swapping implementations (e.g. live vs. mock) straightforward.
-
-```swift
-struct CounterView: View {
- struct Env: Identifiable {
- let id: UUID
- // Declare the API the view layer needs.
- var fetchInitialCount: () async -> Int
-
- static let live = Env(id: UUID(), fetchInitialCount: { await CounterService.shared.count() })
- static let mock = Env(id: UUID(), fetchInitialCount: { 42 })
+ struct Env: Sendable {
+ var search: @Sendable (String) async throws -> [String]
}
- enum Event { case appeared, loaded(Int) }
- struct ViewState { var count: Int? }
-
- @State private var state = ViewState()
- let env: Env
-
- private static func update(state: inout ViewState, event: Event) -> Effect? {
+ static func update(_ state: inout State, event: Event) -> Effect? {
switch event {
- case .appeared:
- return .task { send, env in
- let count = await env.fetchInitialCount()
- await send.perform(.loaded(count))
+ case .queryChanged(let query):
+ state.query = query
+ state.isLoading = true
+ state.errorMessage = nil
+
+ return .run(id: "search") { input, env in
+ try? await Task.sleep(for: .milliseconds(300))
+ guard !Task.isCancelled else { return }
+
+ do {
+ let results = try await env.search(query)
+ try? input.post(.searchResponse(results))
+ } catch {
+ try? input.post(.searchFailed(error.localizedDescription))
+ }
}
- case .loaded(let count):
- state.count = count
+
+ case .searchResponse(let results):
+ state.results = results
+ state.isLoading = false
return nil
- }
- }
- var body: some View {
- EffectView(state: $state, initialEnv: env, update: Self.update) { state, send in
- Text(state.count.map { "\($0)" } ?? "Loading…")
- .task { send(.appeared) }
+ case .searchFailed(let message):
+ state.results = []
+ state.errorMessage = message
+ state.isLoading = false
+ return nil
}
- .id(env.id)
}
}
```
-At the call site, pass the environment that fits the context — no changes to the view or update logic required:
+`update` is the only place that decides how the feature changes.
-```swift
-CounterView(env: .live) // production
-CounterView(env: .mock) // previews, tests
-```
+When `update` returns `nil`, processing stops there. When `update` returns `.run(id: "search")`, the runtime starts that async job, tracks it by identifier, and routes follow-up events back through `update`.
-## Dependency injection via SwiftUI Environment
+That means no `Task?` stored in a ViewModel, no ad-hoc mutation from random callbacks, and no guessing where the last state change came from.
-For dependencies that need to be available deep in the view hierarchy, you can deliver them through the SwiftUI environment using `EnvReader`. Wrap each injectable operation in a lightweight `Action` struct so the environment key stays typed and the default implementation is co-located with the declaration.
+## Use it from SwiftUI
```swift
-// 1. Declare the action
-struct CounterAction: Sendable {
- var fetchCount: @Sendable () async -> Int = { await CounterService.shared.count() }
-}
+struct SearchView: View {
+ @State private var state = SearchFeature.State()
-extension EnvironmentValues {
- @Entry var counterAction = CounterAction()
-}
+ let env: SearchFeature.Env
-// 2. Compose the view's Env from the environment at the call site
-struct CounterContainerView: View {
var body: some View {
- EnvReader(\.counterAction) { action in
- CounterView(
- env: .init(id: UUID(),
- fetchInitialCount: action.fetchCount)
- )
- }
- }
-}
-```
-
-In tests or previews, override just the actions you need:
-
-```swift
-CounterContainerView()
- .environment(\.counterAction, CounterAction(fetchCount: { 42 }))
-```
-
-This keeps each injectable operation minimal and composable. The view layer owns the interface; the environment owns the wiring.
-
-## Recipes
-
-Short, focused snippets for common patterns. Each one highlights a specific feature in isolation.
-
----
-
-### Pull-to-refresh
-
-`perform(_:)` suspends until the full effect chain completes, which makes it a natural fit for SwiftUI's `refreshable` modifier.
-
-```swift
-List(state.movies, rowContent: MovieRow.init)
- .refreshable {
- await input.perform(.refresh) // spinner shown until .refresh effect completes
- }
-```
-
-`.refresh` is just a regular event. The actual async work is a custom effect returned from `update`:
+ EffectView(
+ of: SearchFeature.self,
+ state: $state,
+ initialEnv: env
+ ) { state, input in
+ VStack {
+ TextField(
+ "Search",
+ text: Binding(
+ get: { state.query },
+ set: { try? input.post(.queryChanged($0)) }
+ )
+ )
+
+ if state.isLoading {
+ ProgressView()
+ }
-```swift
-extension Effect where Event == MyView.Event, Env == MyView.Env {
- static func refreshMovies() -> Self {
- .task(name: "refresh") { input, env in
- do {
- let movies = try await env.movieFetch()
- await input.perform(.loaded(movies))
- } catch {
- await input.perform(.loadFailed(error))
+ List(state.results, id: \.self, rowContent: Text.init)
}
+ .padding()
}
}
}
```
----
-
-### Cancel-and-restart (debounce / live search)
-
-Name the task. A new event with the same task name cancels the previous run automatically before starting a fresh one.
-
-```swift
-// update:
-case .queryChanged(let q):
- state.query = q
- return .task(name: "search") { input, env in
- try? await Task.sleep(for: .milliseconds(300))
- guard !Task.isCancelled else { return }
- let results = await env.search(q)
- await input.perform(.resultsLoaded(results))
- }
-```
-
----
-
-### Synchronous action chain (setup sequence)
-
-`.action` returns the next event to process immediately in the same run loop. Use this to break multi-step setup into deterministic, individually-testable events.
-
-```swift
-// update:
-case .appeared:
- return .action { _ in .loadConfig } // processed synchronously before any external event
-case .loadConfig:
- state.config = Config.default
- return .task { input, env in … }
-```
-
-> **Warning:** Action chains run entirely on the `@MainActor` without yielding. A cycle — two events that each produce an `.action` pointing back at the other — will hang the main thread. Prefer `.task` for any work that could repeat or loop.
-
----
-
-### Fire-and-forget (button / gesture)
-
-`input` is callable directly. Use it anywhere a `() -> Void` closure is expected.
-
-```swift
-Button("Retry", action: input(.retry)) // callAsFunction — enqueues on MainActor
-Toggle("Sync", isOn: $state.syncEnabled)
- .onChange(of: state.syncEnabled) { input(.syncToggled($0)) }
-```
-
----
-
-### Cancel before starting
-
-`.sequence` runs effects left-to-right. Useful when you need to cancel a stale task before issuing a new one in the same update step.
-
-```swift
-// update:
-case .refresh:
- return .sequence([.cancel("load"), .task(name: "load") { … }])
-```
-
----
-
-### Await a sub-operation from another task
-
-From inside a `.task`, use `perform(_:)` to drive the FSM and wait for the state change to settle before continuing.
-
-```swift
-return .task { input, env in
- await input.perform(.prepareUpload) // waits for prepareUpload's full effect chain
- let result = await env.upload(…)
- await input.perform(.uploadFinished(result))
-}
-```
-
----
-
-### Observe an external `@Observable` store
+The view renders state and posts events. The feature logic stays in `update`.
-Use `.observe` to mirror a property from an external `@Observable` object into `ViewState`. The handler is called with the initial value immediately and again on every subsequent change. The named task is cancelled automatically when the view disappears.
+## Why this is useful
-Two overloads are available depending on where the observable object comes from.
+- state changes stay local and explicit
+- async work is started from one place
+- repeated work can be replaced by identifier
+- tests can drive `update` with plain values
-**When the store lives in `Env`** — use the env key path overload. The object is resolved inside the task, so nothing is captured at `update` time:
-
-```swift
-struct Env: Identifiable {
- let id: UUID = .init()
- let store: CounterStore // an @Observable @MainActor object
-}
-
-// update:
-case .start:
- return .observe(\.store, keyPath: \.count, name: "observe-count") { input, count in
- await input.perform(.countChanged(count)) // perform waits before the next cycle
- }
-
-case .countChanged(let count):
- state.count = count // the only path that writes the mirrored value
- return nil
-
-case .incrementTapped:
- return .task { _, env in
- await env.store.send(.increment) // write via the store's own event API
- }
-```
-
-**When the store comes from elsewhere** — for example imported into the view by a previous event — use the direct overload and pass the object itself:
+## Installation
```swift
-// update:
-case .storeReceived(let store):
- state.store = store
- return .observe(store, keyPath: \.count, name: "observe-count") { input, count in
- await input.perform(.countChanged(count))
- }
+.package(url: "https://github.com/couchdeveloper/EffectView.git", from: "0.1.0")
```
-The effect holds the object weakly; the observation loop exits automatically if the object is deallocated before the task is cancelled.
-
-> **Requires** iOS 17 / macOS 14 or later (the `@Observable` macro minimum deployment).
-
----
-
-## Contributing
-
-Contributions are welcome. Please follow the [Git workflow](Documentation/GitWorkflow.md) used in this project.
-
-This project uses [Conventional Commits](https://www.conventionalcommits.org/) for all commit messages. After cloning, run the following once to activate the commit message template:
-
-```bash
-git config commit.template .github/commit-template
-```
+Add `EffectView` to your target dependencies.
-The template is included in the repository at `.github/commit-template`.
+## Learn more
----
+- [Recipes](Documentation/Recipes.md)
+- [SwiftUI first](Documentation/SwiftUIFirst.md)
+- [Taming async tasks in SwiftUI views](Documentation/TamingAsyncTasksInSwiftUIViews.md)
+- [Bridging event-driven and imperative code](Documentation/BridgingEventDrivenAndImperative.md)
## License
-Apache License, Version 2.0
+Apache License, Version 2.0
\ No newline at end of file
diff --git a/Sources/EffectView/Effect.swift b/Sources/EffectView/Effect.swift
deleted file mode 100644
index a400966..0000000
--- a/Sources/EffectView/Effect.swift
+++ /dev/null
@@ -1,151 +0,0 @@
-
-/// A value describing a side effect to run after a state transition.
-///
-/// `update` returns an `Effect` to declare what async or synchronous work should
-/// happen next. The effect engine executes it; `update` itself stays synchronous
-/// and free of side effects. `Env` is forwarded to every effect so operations and
-/// actions can access dependencies without capturing them at the call site.
-///
-/// ```swift
-/// // Fire-and-forget task:
-/// return .run(name: "ticker") { input, env in
-/// while true {
-/// try await env.clock.sleep(for: .seconds(1))
-/// input(.tick)
-/// }
-/// }
-///
-/// // Perform-driven task (caller awaits result):
-/// return .request(name: "load") { input, env in
-/// let user = await env.api.fetchUser()
-/// return await input.request(.loaded(user))
-/// }
-///
-/// // Synchronous step — next event returned inline:
-/// return .action { env in
-/// env.analytics.track(.buttonTapped)
-/// return .next
-/// }
-/// ```
-///
-/// ### Generic parameters
-///
-/// - `Event`: The event type of the FSM this effect belongs to.
-/// - `Env`: The dependency environment forwarded into every task and action closure.
-/// - `Output`: The value type returned to a caller suspended on ``Input/request(_:)``.
-/// Use `Void` when no return value is needed.
-public enum Effect {
-
- /// Starts an async operation tracked by the effect engine.
- ///
- /// The `operation` closure receives an ``Input`` handle for dispatching events and
- /// the captured `Env` for dependencies. Named tasks are automatically cancelled when
- /// the view disappears, or when ``cancel(_:)`` is returned from `update` with the
- /// same name. If a task with the same name is already running, it is cancelled before
- /// the new one starts.
- ///
- /// Prefer ``run(name:priority:operation:)`` for fire-and-forget tasks and
- /// ``request(name:priority:operation:)`` for perform-driven tasks rather than
- /// constructing `.task` directly.
- ///
- /// - Parameters:
- /// - name: An optional name used to track and cancel the task. Pass `nil` for
- /// anonymous tasks that run to completion without cancellation support.
- /// - priority: The `TaskPriority` for the launched task. Pass `nil` to inherit
- /// the current task's priority.
- /// - operation: The async work to perform. Returns an optional `Output` value
- /// forwarded to any caller suspended on ``Input/request(_:)``.
- case task(
- name: String? = nil,
- priority: TaskPriority? = nil,
- operation: @Sendable @isolated(any) (Input, Env) async -> Output?
- )
-
- /// A synchronous step that may produce the next event to process immediately.
- ///
- /// The `action` closure receives `Env` and returns the next `Event` to feed back
- /// into `update`, or `nil` to end the chain. The entire chain runs synchronously
- /// on the `@MainActor` before any other work proceeds.
- ///
- /// - Parameter action: A synchronous closure receiving `Env` and returning an
- /// optional next event.
- ///
- /// - Warning: Action chains unwind entirely on the `@MainActor` without yielding.
- /// A cycle — two events that each produce an `.action` pointing back at the other —
- /// will hang the main thread. Use ``run(name:priority:operation:)`` for any work
- /// that could repeat or loop.
- case action(
- action: @Sendable (Env) -> Event?
- )
-
- /// Feeds `event` back into `update` immediately, in the current synchronous turn.
- case event(Event)
-
- /// Cancels the running task with the given name, if any.
- case cancel(String)
-
- /// Runs a list of effects left to right, associating the caller's continuation
- /// with the last effect only.
- ///
- /// ```swift
- /// // Cancel a stale load before starting a refresh:
- /// return .sequence([.cancel("load"), .refreshMovies()])
- /// ```
- ///
- /// - Important: Intermediate effects must be synchronous and terminal (`.cancel`
- /// or side-effect `.action` closures). An intermediate effect that returns an
- /// event is not supported — the event is silently discarded. Use a dedicated
- /// `update` step for event-producing chains instead.
- case sequence([Effect])
-}
-
-extension Effect {
-
- /// Starts a fire-and-forget async task that communicates back through events.
- ///
- /// Use for long-running background work — timers, observers, subscriptions — where
- /// the caller does not need to await a result. The `operation` closure receives an
- /// ``Input`` handle and the captured `Env`; any return value is discarded.
- ///
- /// ```swift
- /// return .run(name: "ticker") { input, env in
- /// do {
- /// while true {
- /// try await env.clock.sleep(for: .seconds(1))
- /// input(.tick)
- /// }
- /// } catch {}
- /// }
- /// ```
- public static func run(
- name: String? = nil,
- priority: TaskPriority? = nil,
- operation: @escaping @Sendable @isolated(any) (Input, Env) async -> Void
- ) -> Self where Env: Sendable {
- .task(name: name, priority: priority) { input, env in
- await operation(input, env)
- return nil
- }
- }
-
- /// Starts an async task whose result is returned to the caller of ``Input/request(_:)``.
- ///
- /// The `operation` closure performs its work, drives the FSM to a completion event
- /// via `await input.request(...)`, and returns the resulting `Output?` to the
- /// original waiter. Use this when the call site needs to `await` the outcome of an
- /// async operation.
- ///
- /// ```swift
- /// return .request(name: "load") { input, env in
- /// let user = await env.api.fetchUser()
- /// return await input.request(.loaded(user))
- /// }
- /// ```
- public static func request(
- name: String? = nil,
- priority: TaskPriority? = nil,
- operation: @escaping @Sendable @isolated(any) (Input, Env) async -> Output?
- ) -> Self {
- .task(name: name, priority: priority, operation: operation)
- }
-}
diff --git a/Sources/EffectView/EffectObservable/EffectObservable.Input.swift b/Sources/EffectView/EffectObservable/EffectObservable.Input.swift
new file mode 100644
index 0000000..763b2df
--- /dev/null
+++ b/Sources/EffectView/EffectObservable/EffectObservable.Input.swift
@@ -0,0 +1,146 @@
+/// A `Sendable` handle for dispatching events into the effect engine.
+///
+/// `EffectObservableInput` provides three dispatch strategies with different semantics:
+/// - ``send(_:)`` — synchronous; must be called from the `@MainActor`.
+/// - ``post(_:)`` — fire-and-forget; safe from any isolation.
+/// - ``request(_:)`` — suspends the caller, returning `Output?`.
+///
+/// ### Isolation and lifetime safety
+///
+/// All state mutations run on the `@MainActor`, a global, app-lifetime
+/// executor. Because the `@MainActor` is never destroyed, ``request(_:)``
+/// is guaranteed to resume its continuation on every code path — no
+/// `withTaskCancellationHandler` bookkeeping is required.
+///
+/// If the calling `Task` is cancelled while awaiting ``request(_:)``,
+/// the suspension continues until the event is processed. Swift does not
+/// automatically resume continuations on cancellation; this is safe
+/// because the `@MainActor` always completes its work.
+///
+/// ### Generic parameters
+///
+/// - `Event`: The event type dispatched into the state machine.
+/// - `Output`: The value returned by ``request(_:)``.
+/// Use `Void` when no return value is needed.
+@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *)
+extension EffectObservable {
+
+ public struct Input: TransducerInput, Sendable {
+
+ init(_ actor: EffectObservable) {
+ self.actor = actor
+ }
+
+ private weak let actor: EffectObservable?
+
+ /// Dispatches `event` synchronously on the `@MainActor`.
+ ///
+ /// Use `send` when you are already running on the `@MainActor` and want the event to be
+ /// processed immediately, in the same synchronous turn. A typical example is a SwiftUI
+ /// button action:
+ ///
+ /// ```swift
+ /// Button("Increment") {
+ /// // Processed before the next await point:
+ /// input.send(.increment)
+ /// }
+ /// ```
+ ///
+ /// "Synchronous" here means that `update` is called inline, any `.action` chain is
+ /// unwound, and the resulting state change is applied — all before `send` returns.
+ /// If `update` returns a `.task`, that task is *launched* synchronously but runs
+ /// concurrently; `send` does not wait for it to finish. Use ``request(_:)`` if you
+ /// need to await the task's completion.
+ ///
+ /// If you want to fire-and-forget the event — scheduling it without waiting for even
+ /// the synchronous `update` pass to complete — use ``post(_:)`` instead.
+ ///
+ /// - Warning: Because `send` unwinds `.action` chains synchronously on the `@MainActor`,
+ /// a cycle in your `update` function — e.g. `.ping` → `.action { .pong }` → `.action { .ping }` → … —
+ /// will loop forever and hang the main thread. ``post(_:)`` and ``request(_:)`` are
+ /// immune because each re-entry is scheduled as a new task, yielding control between iterations.
+ /// - Parameter event: The event to send into the observable runtime.
+ /// - Throws: ``RuntimeUnavailable/actorDeallocated`` if the host observable has
+ /// already been released, plus any error that ``EffectObservable/send(_:)``
+ /// would throw for the same event.
+ @MainActor
+ public func send(_ event: sending Event) async throws {
+ guard let actor else {
+ throw RuntimeUnavailable.actorDeallocated
+ }
+ try await actor.send(event)
+ }
+
+ /// Schedules `event` on the `@MainActor` without awaiting it.
+ ///
+ /// Safe to call from any actor isolation or non-isolated context.
+ /// Use this to fire-and-forget an event from a background task or a
+ /// non-isolated callback without waiting for `update` to run.
+ ///
+ /// - Parameter event: The event to enqueue into the observable runtime.
+ /// - Throws: ``RuntimeUnavailable/actorDeallocated`` if the host observable has
+ /// already been released, or the current latched runtime failure if the runtime
+ /// is no longer accepting work.
+ @inline(__always)
+ public func post(_ event: sending Event) throws {
+ guard let actor else {
+ throw RuntimeUnavailable.actorDeallocated
+ }
+ try actor.checkRuntimeAvailability()
+ Task { @MainActor in
+ try? await actor.send(event)
+ }
+ }
+
+ /// Sends `event` and suspends until the entire resulting effect chain has completed,
+ /// returning the `Output?` value produced by the terminal `.task` closure.
+ ///
+ /// A single event can trigger a cascade: an `.action` may return the next event to
+ /// process immediately, which in turn may return another, and so on. The continuation
+ /// is threaded through the whole chain and only resumed when the chain reaches a
+ /// terminal effect — typically a `.task`, whose async operation runs to completion
+ /// before `request` returns.
+ ///
+ /// ```
+ /// event → [.action chain] → terminal effect
+ /// ├─ .task → Output?
+ /// ├─ .cancel → nil
+ /// └─ nil → nil
+ /// ```
+ ///
+ /// The caller hops to the `@MainActor` for the duration of the call. Because the
+ /// `@MainActor` is a global, app-lifetime executor, the continuation is always
+ /// resumed — no cancellation handler is needed.
+ ///
+ /// - Note: If the calling `Task` is cancelled while suspended,
+ /// `request` continues to wait until the effect chain settles.
+ /// - Note: If the observable runtime has already shut down, `request`
+ /// throws ``RuntimeUnavailable`` immediately instead of entering the runtime.
+ /// - Parameter event: The event to send into the observable runtime.
+ /// - Throws: ``RuntimeUnavailable/actorDeallocated`` if the host observable has
+ /// already been released, plus any error that ``EffectObservable/request(_:)``
+ /// would throw for the same event.
+ /// - Returns: The terminal `Output?` value produced by the settled effect chain.
+ ///
+ /// For usage patterns including `.refreshable`, `task(id:)`, and testing,
+ /// see .
+ @discardableResult
+ public func request(
+ _ event: Event
+ ) async throws -> Output? where Output: Sendable, Event: Sendable {
+ guard let actor else {
+ throw RuntimeUnavailable.actorDeallocated
+ }
+ return try await actor.request(event)
+ }
+
+ /// Convenience call-as-function syntax for ``post(_:)``.
+ ///
+ /// - Parameter event: The event to enqueue into the observable runtime.
+ /// - Throws: Any error that ``post(_:)`` would throw for the same event.
+ @inline(__always)
+ public func callAsFunction(_ event: sending Event) throws {
+ try post(event)
+ }
+ }
+}
diff --git a/Sources/EffectView/EffectObservable/EffectObservable.swift b/Sources/EffectView/EffectObservable/EffectObservable.swift
new file mode 100644
index 0000000..d435ffe
--- /dev/null
+++ b/Sources/EffectView/EffectObservable/EffectObservable.swift
@@ -0,0 +1,282 @@
+import Observation
+
+@available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, visionOS 1.0, *)
+@MainActor
+@Observable
+/// Observable host for a transducer-driven runtime.
+///
+/// `EffectObservable` stores the current transducer `State`, exposes it
+/// through Swift Observation, and provides an ``Input`` handle for sending
+/// events back into the runtime from views, tasks, and callbacks.
+///
+/// Construct the observable once for the host view's lifetime. The runtime
+/// captures `Env` at initialization, updates ``state`` only through the
+/// transducer, and keeps event processing serialized on the `@MainActor`.
+public final class EffectObservable<
+ T: Transducer,
+> where
+ T.Output: Sendable,
+ T.Env: Sendable,
+ T.Effect == TransducerEffect,
+ T.Event: Sendable
+{
+ public typealias State = T.State
+ public typealias Event = T.Event
+ public typealias Env = T.Env
+ public typealias Output = T.Output
+ public typealias Effect = T.Effect
+
+ typealias Storage = UnownedReferenceKeyPathStorage
+ typealias Send = EffectView::Send
+
+
+ /// Current transducer state published through Swift Observation.
+ internal(set) public var state: State
+
+ @ObservationIgnored
+ private var send: Send?
+ @ObservationIgnored
+ nonisolated(unsafe) private var runtimeUnavailable: RuntimeUnavailable?
+ @ObservationIgnored
+ private var initialEvent: Event?
+ @ObservationIgnored
+ private var storage: Storage!
+ @ObservationIgnored
+ private var _input: Input!
+
+
+ /// Creates an observable runtime with a captured dependency environment.
+ ///
+ /// Use `initialEvent` to kick off startup work after construction. The
+ /// event is scheduled asynchronously; the initializer does not wait for
+ /// that work to finish before returning.
+ ///
+ /// - Parameters:
+ /// - of: The transducer type.
+ /// - initialState: The initial value for ``state``.
+ /// - initialEvent: An optional event sent when the view first appears.
+ /// - env: Dependencies captured for the runtime lifetime.
+ public init(
+ of: T.Type = T.self,
+ initialState: State,
+ initialEvent: Event? = nil,
+ env: Env
+ ) {
+ self.state = initialState
+ self.initialEvent = initialEvent
+ self.storage = .init(host: self, keyPath: \.state)
+ let send = T.makeSend(
+ with: Input.self,
+ storage: storage,
+ env: env
+ )
+ self._input = Input(self)
+ self.send = send
+ if let event = initialEvent {
+ Task {
+ do {
+ try await send(event, input: _input, continuation: nil)
+ } catch {
+ print("could not process initial event: \(error)")
+ // TODO: consider sending a control event
+ }
+ }
+ }
+ }
+
+ isolated deinit {
+ cancel()
+ }
+
+ /// Sends the given event into the transducer.
+ ///
+ /// The event will be processed by the transducer's update function, which may
+ /// return an effect which may itself return an event. This event is synchronously
+ /// processed by the update function. The chain of events is processed until no
+ /// further events are returned.
+ ///
+ /// If the update function returns a task effect, this task will be started. This
+ /// also terminates the event processing chain and `send` returns.
+ ///
+ /// When `send` returns the transducer has fully processed the event, that is it has
+ /// updated its state accordingly, and started all effect tasks returned by the
+ /// update function during processing of the event. However, any async operations
+ /// in those tasks may continue to run.
+ ///
+ /// - Caution: `send` preserves ordered inline reduction. If the current event chain
+ /// reaches a long-running awaited step, the caller remains suspended until that
+ /// step yields control back to the runtime.
+ ///
+ /// - seealso: **Effect Operations and Actions**
+ ///
+ /// - Note: `send` may suspend only when it executes suspending effect actions.
+ ///
+ /// - Parameter event: The event that is sent into the system.
+ /// - Throws: ``RuntimeUnavailable/actorCancelled`` if the runtime has already been
+ /// cancelled, ``RuntimeUnavailable/systemError`` if the runtime has latched a
+ /// critical failure, or `CancellationError` if accepted work is later cancelled.
+ public func send(_ event: Event) async throws {
+ try checkRuntimeAvailability()
+ guard let send = send else {
+ throw RuntimeUnavailable.actorCancelled
+ }
+ do {
+ try await send(event, input: _input, continuation: nil)
+ } catch {
+ let boundaryError = runtimeBoundaryError(for: error)
+ if let runtimeUnavailable = boundaryError as? RuntimeUnavailable {
+ self.runtimeUnavailable = runtimeUnavailable
+ }
+ throw boundaryError
+ }
+ }
+
+ /// Sends `event` and suspends until operations of the resulting effect chain complete.
+ ///
+ /// The event will be processed by the transducer's update function, which may
+ /// return an effect which may itself return an event. This event is synchronously
+ /// processed by the update function. The chain of events is processed until no
+ /// further events are returned.
+ ///
+ /// If the update function returns a task, this task will be executed and `request` will
+ /// suspend until the task's operation completes, returning the task's output.
+ /// This also terminates the event processing chain.
+ ///
+ /// When `request` returns the transducer has fully processed the event, that is it has
+ /// updated its state accordingly, and started and awaited the effect task returned
+ /// by the update function during processing of the event. In the mean time, the
+ /// transducer can receive and process other events, but the caller is suspended until
+ /// the effect task triggered by this event has completed.
+ ///
+ /// - seealso: **Effect Operations and Actions**
+ ///
+ /// ## Effect Operations and Actions
+ ///
+ /// If the chain reaches a named task, overlapping waiters for the same task
+ /// identifier are coalesced according to that task's ``TaskExecutionOption``.
+ /// The caller is waiting for the current active task for that identifier, not
+ /// necessarily for the first physical task instance that was started.
+ ///
+ /// A single event can trigger a cascade: an `.action` may return the next event to
+ /// process immediately, which in turn may return another, and so on. The continuation
+ /// is threaded through the whole chain and only resumed when the chain reaches a
+ /// terminal effect — typically a `.task`, whose async operation runs to completion
+ /// before `request` returns.
+ ///
+ /// ```
+ /// event → [.action chain] → terminal effect
+ /// ├─ .task → Output?
+ /// ├─ .cancel → nil
+ /// └─ nil → nil
+ /// ```
+ ///
+ /// - Caution: Cancelling the caller does not immediately tear down an accepted request.
+ /// The runtime resumes the continuation only after the in-flight chain reaches a
+ /// terminal outcome or the runtime reports cancellation.
+ ///
+ /// - Parameter event: The event that is sent into the system.
+ /// - Throws: ``RuntimeUnavailable/actorCancelled`` if the runtime has already been
+ /// cancelled, ``RuntimeUnavailable/systemError`` if the runtime has latched a
+ /// critical failure before the request can enter or while it is running, or
+ /// `CancellationError` if accepted work is later cancelled.
+ /// - Returns: the `Output?` value produced by the terminal `.task` closure.
+ public func request(_ event: Event) async throws -> Output? {
+ // TODO: consider to to add a Task cancellation handler which sends a corresponding control event to the transducer.
+ // The transducer's action on this is currently "implementation defined". It *could* have no effect on the task operation, or it *could* cancel it.
+ try checkRuntimeAvailability()
+ guard let send = self.send, let input = _input else {
+ throw RuntimeUnavailable.actorCancelled
+ }
+ return try await withCheckedThrowingContinuation { (continuation: Continuation