Writing state
turn is the only write primitive, and application code never calls it inline. Every mutation goes through a named domain operation, and every operation publishes exactly one atomic turn. Those two rules are most of this chapter; the rest is what they combine into.
Wrap every primitive in a named op
turn and refresh are how the graph is asked to do something. They are not what an app calls the asking. Application code — a view action, a button, a mechanism — calls a domain verb from a CogOps extension, never the primitive inline:
extension CogOps {
/// Replaces the search text with the field's latest value.
func setSearchQuery(_ query: String) {
turn(_searchQueryCog, to: query)
}
/// Demands a fresh forecast for one ZIP.
func refreshForecast(for zip: ZipCode) {
refresh(weatherForecastCogs[zip])
}
}The rule covers refresh for the same reason it covers turn: both are demands on the graph, and neither is domain vocabulary. Keeping the primitive inside the state layer also keeps things findable. A reader of cogs.refreshForecast(for:) finds the async declaration, its service dependency, and the op in one file.
Ops extend CogOps, so one definition serves every caller. Views call ops on cogs. A mechanism calls the same ops on its controller m. A gated scope calls them on its sub-controller s.
One op, one turn
One outer turn call is one graph turn. Every source staged inside the body publishes together, and no observer can ever see a halfway state. Use the block form whenever a domain action touches more than one fact:
/// Jumps to one trail on the Explore tab from anywhere in the app.
func showTrailInExplore(_ trailID: TrailID) {
guard let trail = TrailCatalog.trail(trailID) else { return }
turn { c in
c[_selectedTabCog] = .explore
c[_tabPathCogs[TrailTab.explore]] = [.region(trail.regionID), .trail(trailID)]
c[_presentedSheetCog] = nil
}
}Inside the body, the Writer sees that turn's staged values. A later line reading c[_tabPathCogs[…]] sees what an earlier line wrote. Writing a value equal to the current one is discarded by the turn itself — that is what makes redundant system-driven binding writes free (SwiftUI integration).
Two things a turn body must not do. It must not read-modify-write across an await, because a turn is synchronous. And it must not write from inside an automatic computation — the runtime rejects that with a clear error.
Compose across files with nested turns
A nested turn joins the outer turn. This is the idiom that lets each rig keep its sources file-private and still take part in atomic cross-rig actions. An op calls the other file's op inside its turn body, and both publish as one turn:
/// Commits a hike entry and dismisses the logger in one settled turn.
func logHike(for trailID: TrailID, note: String, …) {
turn { c in
c[_hikeEntriesCog] = [entry] + c[_hikeEntriesCog]
self.dismissSheet() // nested turn joins; entry and dismissal publish together
}
}Trails uses the same idiom for restoration (installTrailState calls installNavigation) and for deep links (open(.search) calls setSearchQuery). The composed op never needs the other rig's sources — only its public operation. So the write boundary drawn by file privacy survives composition.
Reaction writes are later turns
A write from a reaction — say, a mechanism's watch handler calling an op — does not join the turn it observed. It waits in a FIFO queue and becomes its own turn after the current flush. That is the right mental model for anything event-shaped: the journal entry recording a navigation lands one turn after the navigation itself.
Mechanisms can still form a turn → reaction → turn loop. If one spins, a debug guard warns after about 64 turns and prints the named cause chain.
Discard: release state you no longer need
Suppose each trail screen has a filter and an unfinished note. When the user closes one screen, the app should stop watching its filter and forget its unfinished note.
Those are two steps:
- Remove the screen's ID from the open-screen list. Its
scope(each:)ends, removing its watches and cancelling its tasks. - Call
.discard(...)for the saved values that belonged to that screen.
Ending a scope does not clear its state. Cog also cannot reliably tell when the last SwiftUI view stops reading a value. Once a view has read it, Cog keeps it for the app's lifetime unless the app explicitly releases it. Without cleanup, opening hundreds of screens with different IDs can leave hundreds of old filters and notes in memory.
Declare values that can start over
This small example extends the Trails idea with a separate ID for each screen opening. It is a sketch, not code from the example app.
First, give temporary manual state permission to be released:
// TrailRig+Cogs.swift
private let _openTrailScreensCog = Cog<[TrailScreenID]>.Manual { [] }
let openTrailScreensCog = _openTrailScreensCog.readOnly
private let _trailFilterCogs = CogBox<String, TrailScreenID>.Manual(
{ "" },
lifetime: .whileObserved(resetToInitial: true)
)
let trailFilterCogs = _trailFilterCogs.readOnly
private let _trailDraftNoteCogs = CogBox<String, TrailScreenID>.Manual(
{ "" },
lifetime: .whileObserved(resetToInitial: true)
)
let trailDraftNoteCogs = _trailDraftNoteCogs.readOnlyTrailScreenID is a Hashable ID created once for each screen opening. Each ID gets its own filter and note. Both start as an empty string.
resetToInitial: true means: "It is okay to forget this value. If something reads it after release, start again with the initial value." The policy allows unused state to expire, but a previous UI read keeps it alive until an explicit discard.
Close the screen, then release its values
Put both steps in the screen-closing op, in the same file as the sources:
extension CogOps {
func closeTrailScreen(_ id: TrailScreenID) {
// Updating the list ends this screen's scope.
turn { c in c[_openTrailScreensCog].removeAll { $0 == id } }
// Release this screen's saved values.
discard(_trailFilterCogs[id])
discard(_trailDraftNoteCogs[id])
}
}A view calls cogs.closeTrailScreen(screenID). A mechanism can call the same op through a controller that stays live after the screen closes. Avoid calling it through the screen's own s: ending that scope disables its controller, so its later discard calls would do nothing.
Imagine A's filter is "easy" and B's filter is "nearby". With no other watcher or cog holding A's values, closing A has this result:
| Value or work | After closeTrailScreen(A) |
|---|---|
| Open screen IDs | A is removed; B stays. |
| A's scope | Its watches stop and its tasks are cancelled. |
| A's filter and unfinished note | Released from Cog. |
| B's filter | Still "nearby". |
| Shared trail details | Unchanged. The op did not discard them. |
If code later reads A's filter, Cog creates it again as "". Discarding releases the stored value; it does not delete the declaration or prevent future reads. A still-reading view is notified after release so it can read the fresh value.
Choose what to discard
Call discard on each exact value the app is finished with, such as _trailFilterCogs[id]. It does not clear a whole box or everything a scope read. Shared trail details belong to the trail, so closing one screen is not a reason to discard them.
The API follows these rules:
- Manual state must allow release. Use
.whileObserved(resetToInitial: true)as above. Trying to discard existing.appstate causes a runtime error. - Automatic state can be discarded too. An ordinary synchronous
Coguses.whileObservedby default and recomputes on its next read. Pass the automatic reference itself. For manual state, pass the underscored source, as above, rather than its.readOnlyprojection. - Other active users can keep the value. If a watch, exported stream, or another cog still holds it,
discardleaves it alone. A UI read is different: the explicit discard can release that value and notify the view. - Async cogs have no
discardoverload. Their work is managed by their demand and lifetime rules. - A value that was never created needs no cleanup. The call does nothing.
Each discard runs as its own turn when graph work is safe. It runs immediately when Cog is idle, or waits until the current turn finishes. Keep it after the closing turn so the screen's watches can end first.
If the screen stays open and the user just wants to clear its text, write "" in a normal turn. Use discard when the app is finished with the stored value, not as a Clear button.
Where this is specified
Turn semantics and the write model are core design §3. Reaction ordering is mechanisms §6.4.