Why it is shaped this way
The store is the UI's state
Records are observable objects. A view body that read a field is invalidated when that field of that record changes and at no other time. Reads are synchronous on the main actor and cost nanoseconds. The main actor reads, commits, checks what the store holds, collects, and normalizes an optimistic response; decoding and normalizing a response and the image's writes run off it.
Records are observable objects. A view body that read a field is invalidated when that field of that record changes and at no other time. Reads are synchronous on the main actor and cost nanoseconds. The main actor reads, commits, checks what the store holds, collects, and normalizes an optimistic response; decoding and normalizing a response and the image's writes run off it.
Why#
Relay built a subscription machine because React could not tell it which component read which field: snapshots, seen-record sets, re-reads on overlap, structural recycling to keep references stable. Apollo's native clients built less and got less: watchers per query that re-execute the whole operation when any field it read changes, with no equality check on iOS, and store reads that are asynchronous on both platforms, so a warm cache still shows a loading frame. One production profile found a third of all cache CPU spent recomputing watcher keys.
The opposite failure is to do nothing: hand views plain values and let the whole screen re-render on every response. Fast to build, impossible to scale past a list.
The idea#
The server answers TestNotesQuery(id: "1") with tests/notes-page-1.json, and the environment commits it as a server batch.
tests/notes-page-1.json
{
"data": {
"character": {
"id": "1",
"name": "Rick Sanchez",
"notes": {
"totalCount": 5,
"edges": [
{
"cursor": "c1",
"node": {
"__typename": "Note",
"id": "n1",
"text": "Wubba lubba dub dub"
}
},
{
"cursor": "c2",
"node": {
"__typename": "Note",
"id": "n2",
"text": "Portal gun needs charging"
}
}
],
"pageInfo": {
"endCursor": "c2",
"hasNextPage": true
}
}
}
}
}Notified nothing: no observed field changed, so no body runs
The server answers TestNotesQuery(id: "1") with tests/notes-page-1-renamed.json, and the environment commits it as a server batch.
tests/notes-page-1-renamed.json
{
"data": {
"character": {
"id": "1",
"name": "Rick C-137",
"notes": {
"totalCount": 5,
"edges": [
{
"cursor": "c1",
"node": {
"__typename": "Note",
"id": "n1",
"text": "Wubba lubba dub dub"
}
},
{
"cursor": "c2",
"node": {
"__typename": "Note",
"id": "n2",
"text": "Portal gun needs charging"
}
}
],
"pageInfo": {
"endCursor": "c2",
"hasNextPage": true
}
}
}
}
}Notified Character:1.name
SwiftUI tracks which observable properties a body reads and re-runs only the bodies whose dependencies changed. Compose does the same through snapshot state. A normalized record is exactly the "one small observable object per item" the platform recommends. So each record carries a registrar; a lens accessor registers the read of one field of one record through a static key path; a commit notifies changed fields of records that someone observed. The runtime has no subscriptions of its own.
Reads are synchronous because view bodies are synchronous, and because a cached screen must render in its first frame. The store therefore lives on the main actor, with the work that has to touch its records: the commit, the availability check, which reads the image when memory lacks a record, and collection. One more thing runs there by design: an optimistic response, a value the call passes, is normalized on the main actor, so its layer shows in the same turn as the call rather than a frame later. Nothing else does: a response's bytes are decoded and normalized off it, in the fetch's own task, and the image is written behind. A commit writes the slots that changed and notifies the observed fields that changed. Why the check and collection stay on the main actor, and the numbers that keep them there, is a decision of its own.
Consequences#
- No asynchronous read API exists for views. A handle resolves against the store before the first body; data that is present is present immediately.
- The store detects change before it notifies: a write of an equal value is not a change.
- Commits are atomic. A view never sees half a response, and an optimistic update that is reverted and re-applied in the same commit notifies only the net difference.
withAnimation { commit }animates data-driven changes, because a commit is ordinary main-actor state mutation.- UIKit gets the same precision through automatic observation tracking.
- The cost is Observation's and nobody else's: a tracked field read costs
what an
@Observableproperty costs; an untracked one less.
Not this#
- A store behind an actor or a lock that views must
await. - Query-level watchers that re-execute an operation and emit a tree.
- A subscription per fragment instance with its own bookkeeping.
- Notifying on every write without comparing values.
- Any work on the main actor besides reads, commits, the availability check, collection and an optimistic response's normalization, or any of those growing past a frame.
See A fragment is a lens for where a read is registered and The response is the oracle for what a commit must preserve.
Spelled today#
Record is the observable object, with one invalidation channel per slot:
a key path through one subscript, made on first use and shared by every
record. Store.commit(_ changes:) runs on the main actor; Ingest decodes
off it, in the fetch's own task; Environment holds the store and runs
the ingest for its fetches. This section may
rot; the rest must not.
Baton