Skip to content

Side effects

Every app-wide side effect has one home: a Mechanism, registered at assembly. Mechanisms live in the rig's +Mechanisms.swift file, own their capabilities as stored properties, and touch the graph only through the controller they are handed.

The mechanism shape

swift
struct TrailJournalMechanism: Mechanism {
  func operate(_ m: MechanismController) {
    m.watch(currentScreenCog, initial: .run, name: "journal") { [weak m] _, screen in
      m?.recordScreenVisit(screen)
    }
  }
}

The conventions that keep mechanisms predictable:

  • Dependencies are stored properties, injected at assembly. A capability the mechanism owns — a store, a notifier, a clock — arrives through its initializer. Production passes a .live value; tests pass a double: TrailPersistenceMechanism(store: .live).
  • Name every registration. Names compose under the mechanism name (Trail.persistence, Weather.session.heartbeat). They are what debug history and Instruments show.
  • Capture the controller weakly in anything long-lived. A task or reaction holds [weak m] and stops when the scope is gone, so teardown never waits on cancelled code.
  • Inject clocks. Timed work sleeps on a stored any Clock<Duration> that defaults to ContinuousClock(), so tests can substitute a controlled clock (Testing).

Initial app state belongs in operate

operate runs inside assembly. Its writes finish before assemble returns, so no watcher ever observes the pre-initial value on the way past. The app entry point assembles and retains the runtime; it does not write to it.

swift
func operate(_ m: MechanismController) {
  m.installTrailState(store.load() ?? Self.firstRun)   // settled before launch finishes

  m.watch(trailSnapshotCog, initial: .skip, name: "persistence") { _, snapshot in
    store.save(snapshot)
  }
}

A test sets up the same starting world by passing the same mechanism to Cogs.forTesting(mechanisms:). Note that forTesting's seeding: closure is not the production counterpart of this. Seeding installs values with no turn, before anything watches — a testing need, nothing more.

The persistence pattern

Both TodoMVC and Trails persist through the same three-part shape. Copy it:

  1. A snapshot cog — one automatic value that gathers everything durable from one settled turn, so storage always sees a coherent document (trailSnapshotCog).
  2. A store capability — a small struct with injected load/save closures and a .live value backed by UserDefaults or a file. Storage never becomes a second live source: it is read once during assembly, then only written.
  3. An install-then-watch mechanismoperate installs store.load() ?? firstRun through a named op, then watches the snapshot cog with initial: .skip and saves each later value.

Restoration this way is invisible. The writes finish during assembly, so the first rendered frame is already the restored screen — no flash of the defaults. This pattern treats storage as a cache of graph state. Work whose durable record must survive the process dying has stricter ordering rules; see mechanisms §6.7.

Scopes: start and stop work with state

A hike timer should tick while the hike logger is open and stop when it closes. With .scope(...), you describe that rule once. Cog starts and stops the work as the state changes.

Two APIs help when a screen closes:

APIWhat it controlsExample
.scope(...)How long tasks and watches runStop the hike timer when the logger closes.
.discard(...)How long a saved value stays in CogRelease an unfinished note when its screen closes.

Ending a scope does not clear the values its work used. The operation that closes the screen can also call discard for values the app no longer needs. Writing state walks through that part.

Start with a Bool: run a timer while the logger is open

In Trails, isLoggingHikeCog is true while the hike logger sheet is open. Here is the timer's shape:

swift
struct HikeTimerMechanism: Mechanism {
  var clock: any Clock<Duration> = ContinuousClock()

  func operate(_ m: MechanismController) {
    m.scope(isLoggingHikeCog, name: "hikeTimer") { s in
      s.resetHikeTimer()
      s.task(name: "tick") { [weak s] in
        while true {
          try await clock.sleep(for: .seconds(1))
          guard let s else { return }
          await s.tickHikeTimer()
        }
      }
    }
  }
}

m is the mechanism's controller. s is a new controller for this opening of its scope. Register work through s so Cog knows which work to stop. resetHikeTimer and tickHikeTimer are the app's named operations.

Logger stateWhat happens
Starts closed (false)No timer starts.
Opens (true)The scope body runs once: reset the timer, then start ticking.
Stays open (true)The same task keeps ticking. The scope body does not run again.
Closes (false)Cog cancels the ticking task.
Opens again (true)A new scope runs the body again and starts a new timer.

If the logger is already open when the scope is registered, the body runs then. Closing the scope also removes any watches registered through s.

The timer returns to zero because the body calls resetHikeTimer(). A scope does not reset graph state on its own. A value that should survive closing and reopening can stay in the graph.

Derive isLoggingHikeCog from navigation state. That way, a swipe to dismiss the sheet stops the timer just as a Close button does.

One screen at a time: select its ID

A Bool works when you only need to know whether something is open. Sometimes you also need to know which screen is open.

Suppose a trail screen watches changes to its filter. The user switches from screen A to screen B. An isOpenCog would stay true, so a Bool scope would keep A's watch running. Instead, use a cog holding the current screen's ID, or nil when no screen is open:

swift
// activeTrailScreenCog holds a TrailScreenID? value.
// This sketch extends the Trails idea; it is not code from the example app.
m.scope(activeTrailScreenCog, name: "trailScreen") { screenID, s in
  s.watch(trailFilterCogs[screenID], initial: .skip, name: "filter") { _, filter in
    analytics.record(.filterChanged(filter), screen: screenID)
  }
}

The body receives the selected ID as screenID. It uses that ID to watch only this screen's filter. Here, analytics is an injected service, and initial: .skip means it records later changes, not the starting filter.

Selected IDWhat happens
nil → AStart A's scope and its filter watch.
A → AKeep the same scope and watch.
A → BStop A's scope, then start B's.
B → nilStop B's scope.

The same rule works for a login session or a checkout. Use an optional ID when replacing one with another should replace the work, even if there is no closed or signed-out step between them.

Create the ID in the operation that opens the screen. Keep it unchanged while that screen is open. Do not create a new ID in an automatic cog each time it computes, or ordinary state changes could keep restarting the work.

Give each opening its own ID. Two screens can show the same trail but have different filters and separate work. A trail's ID identifies the trail; a screen's ID identifies one opening of that screen.

Several screens at once: use scope(each:)

For a navigation stack, keep the open screen IDs in an array. Register one scope(each:) for that array:

swift
// openTrailScreensCog holds [TrailScreenID].
m.scope(each: openTrailScreensCog, name: "trailScreen") { screenID, s in
  s.watch(trailFilterCogs[screenID], initial: .skip, name: "filter") { _, filter in
    analytics.record(.filterChanged(filter), screen: screenID)
  }
}

Cog gives each ID its own scope and controller:

Open screen IDsWhat happens
[][A]Start A's scope.
[A][A, B]Keep A running; start B's scope.
[A, B][B, A]Keep both running. Reordering restarts nothing.
[B, A][B]Stop A's scope; keep B running.
[B][]Stop B's scope.

Every ID in the array must be unique; duplicates cause a runtime error. If an ID is removed and added back in a later turn, it gets a new scope.

A screen covered by another screen is still in the stack, so its work keeps running. For work that should last only while a view is visible, use SwiftUI's .task (SwiftUI integration).

Use one registration for the collection in operate. You do not need to register a new scope each time the user opens a screen.

All three forms respond to the values at the end of a turn. For example, removing A and putting it back in the same turn does not restart A's scope.

When async work finishes after a scope ends

A network request may finish after its screen closes. Cancelling a task asks it to stop; it does not guarantee that the task stops immediately.

Cog also disables the ended scope's controller. The docs call this retirement. A turn called through that controller does nothing, even if it was queued before the scope ended. This prevents the old work from writing through the old controller.

Publish a result through a named op, using the screen ID captured when the work started:

swift
s.task(name: "load") { [weak s] in
  let trail = try await service.load(trailID)
  await MainActor.run { s?.acceptTrail(trail, receipt: screenID) }
}

// TrailRig+Cogs.swift
extension CogOps {
  func acceptTrail(_ trail: Trail, receipt: TrailScreenID) {
    turn { c in
      guard c[_openTrailScreensCog].contains(receipt) else { return }
      c[_loadedTrailCogs[receipt]] = trail
    }
  }
}

Here, receipt is the ID of the screen that requested the load. The guard checks that it is still open before saving the result. Keeping that check inside turn also protects calls through other, still-live controllers.

Avoid reading through an ended controller: peek, status.peek, and refresh cause a runtime error. If late work needs to read before deciding what to do next, use ifLive:

swift
guard let s, s.ifLive({ $0.peek(isRefreshableCog) }) == true else { return }

ifLive returns nil if the scope has ended. It checks only when called; check again after each await. It does not keep the scope alive, and code inside its closure can still end the scope.

Other calls through an ended controller are harmless: run, watch, status.watch, and scope start nothing. task returns an already-cancelled task without running its body. discard does nothing too, so call the screen-closing op through cogs or a controller that outlives the screen.

Reads inside a scope

The selected Bool, ID, or ID array decides when a scope starts and stops. A peek inside the body reads a value once; changes to that value do not restart the scope. A watch or run registered through s tracks its own reads as usual.

Task closures are nonisolated. To change state from one, await a named op, as in await s.tickHikeTimer() above. Inject clocks so tests can control time.

Where this is specified

The full mechanism model — the controller surface, ordering guarantees, view-scoped effects, testing, and background execution — is mechanisms §6.

Released under the MIT License.