Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .swiftpm/xcode/xcshareddata/xcschemes/EffectView.xcscheme
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EffectView"
BuildableName = "EffectView"
BlueprintName = "EffectView"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EffectViewTests"
BuildableName = "EffectViewTests"
BlueprintName = "EffectViewTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnableBacktraceRecording = "Yes">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EffectView"
BuildableName = "EffectView"
BlueprintName = "EffectView"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
48 changes: 36 additions & 12 deletions Documentation/ArchitecturalComparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -119,42 +119,44 @@ 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))
}
```

**Dispatch semantics:** `Input` exposes three levels, chosen at the call site:

| 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.
Expand All @@ -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<State>` 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
Expand All @@ -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.
Expand Down
24 changes: 13 additions & 11 deletions Documentation/BridgingEventDrivenAndImperative.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
```

Expand All @@ -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
}
Expand Down Expand Up @@ -77,7 +79,7 @@ consequence of that event.

```swift
.refreshable {
await input.request(.refresh)
try? await input.request(.refresh)
}
```

Expand All @@ -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() }
}
}
Expand All @@ -106,7 +108,7 @@ settled:
```swift
.task(id: appPhase) {
if appPhase == .active {
await input.request(.resumeIfNeeded)
try? await input.request(.resumeIfNeeded)
}
}
```
Expand All @@ -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)
```

Expand All @@ -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()`

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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.
17 changes: 10 additions & 7 deletions Documentation/CorrectByConstruction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<SearchEvent, SearchEnv>? {
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))
}
}
])
Expand Down Expand Up @@ -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)):
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading