Stream JSON into SwiftUI progressively, record by record
Rebuilding the whole view on every chunk is a compensation for not having complete units. Once each unit is complete, ordering becomes checkable and rollback costs three lines.

A chunk lands, the view updates, and the demo looks finished. What decides whether it ships is the question underneath: between chunks, what is the view allowed to become?
To stream JSON into SwiftUI progressively you need an answer for each intermediate state, not just the final one, and there are only two ways to get one. You can rebuild the whole view from everything received so far — which is what most published implementations do — or you can apply one complete, checked record to the state you already had. The second is more work up front and it is the one that lets you say, before shipping, which screens your app can produce.
The short version
- Buffer to a delimiter. A record is not a record until its terminator arrives.
- Decode and check it on its own. Shape, then whether this record type is legal in the current state.
- Apply it to a copy of the reducer. Not to the live one.
- Assign the copy back only if the apply succeeded. A rejected record leaves the last valid screen exactly as it was.
- Keep identity stable. SwiftUI diffs what it can identify; anything it cannot identify it re-creates, and that is what "flicker" means.
Why rebuilding the whole state on every chunk breaks
The best-written account of the alternative is worth reading and worth disagreeing with precisely. SAFE Engineering's "From Stream to Screen" (21 January 2025, checked 18 August 2026) describes a pipeline for GenAI responses in SwiftUI in which "the app rebuilds the entire UI state after each chunk of data arrives". Two mechanisms hold that together: hierarchical ID generation, to keep element identity stable across rebuilds, and an 8 ms throttle, to keep the main thread responsive. Its error policy is stated just as plainly: "Keep valid elements even if there was an error", "Collect errors but continue". It assumes the content "is always valid when fully received".
For rich chat responses that is a good design. The author of the content is trusted, the worst case is a malformed markdown table, and continuing past an error is better than showing the user nothing.
Notice what the two supporting mechanisms are for. Hierarchical IDs exist because rebuilding destroys identity and it has to be reconstructed. The throttle exists because rebuilding is expensive enough to need rationing. Both are costs paid to make rebuilding feel free, and both disappear if you never rebuild.
And the error policy has a consequence that is easy to miss: a screen assembled from whichever records happened to parse is a screen nobody designed. You cannot enumerate the set of screens your app can produce, which means you cannot review it, and "what does this look like when three of the eleven records fail?" has no answer other than running it.
Apply one record to the previous state
Once each unit arrives complete — one JSON object per line, checked before it is forwarded — rebuilding stops being necessary. A reducer takes the state you already had and one new record, and produces the next state.
func ingest(_ line: Data) throws {
let frame = try decoder.decode(SDUIFrame.self, from: line)
guard var next = reducer else {
throw PresentationStreamError.expectedScreenStart
}
try next.apply(frame) // throws on an illegal record
reducer = next // reached only if apply did not throw
guard let screen = next.screen else { return }
updateProgressiveScreen(screen)
}That is the whole loop. Each record does one thing to one place: a callout appends a block, a collection_item appends to the collection currently open, a comparison_row adds a row to the comparison currently accepting rows. Nothing is recomputed, so nothing needs throttling, and identity is preserved because the existing values were never thrown away.
Order is a grammar, not a convention
The part that makes apply safe is that "the collection currently open" is a real thing the receiver knows, not an assumption.
The stream has states, and each state declares which record types it accepts and which state each one moves you to. This is the shipped table, and it is a data file rather than a chain of if statements:
{
"screen": {
"narrative": "screen",
"collection_start": "collection",
"comparison_start": "comparison_subjects",
"handoff_bar": "handoff",
"screen_commit": "committed"
},
"comparison_subjects": {
"comparison_subject": "comparison_subjects",
"comparison_row": "comparison_rows"
},
"comparison_rows": {
"comparison_row": "comparison_rows",
"comparison_end": "screen"
},
"handoff": { "screen_commit": "committed" },
"committed": {}
}Read what that rules out. A comparison_row arriving before any subject is not an edge case to be tolerated — comparison_subjects is the only state that can produce comparison_rows, so a row with no subjects has no legal path. A second screen_start is not a state at all. Nothing follows screen_commit, because committed accepts nothing. Groups do not nest, and the table is how that is enforced: no state that is inside a group lists another group-start record.
This is the difference between ordering as a convention and ordering as a grammar. A convention is a sentence in a document that a model has read and a reviewer hopes holds. A grammar is a table both ends can check, and "what happens if records arrive out of order?" becomes a question with a mechanical answer instead of an unhandled one.
A rejected record must not touch the screen
Having a rule that rejects a record is only useful if rejection is free. In Swift it is, and the reason is a language property rather than a framework one.
Make the reducer a struct. Then this:
var next = reducer // value semantics: a copy
try next.apply(frame) // mutates the copy, or throws
reducer = next // never reached if apply threwIf apply throws, next is discarded and reducer still holds the state from before the record arrived. There is no rollback path to write, no snapshot to restore, no half-applied mutation to undo — the copy was the snapshot. A partially applied record cannot exist, because the only mutation that survives is the one that ran to completion.
Rollback is not a feature you build here. It is what value semantics already do, provided the reducer is a struct and you never mutate the live one.
That is the same principle as rejecting invalid model output rather than repairing it, enforced by the type system instead of by discipline. The reducer's own documentation states the invariant it is there to hold: a failed record never mutates the last valid render state.
Streaming without flicker is an identity problem
Flicker is not a rendering artefact. It is SwiftUI correctly re-creating a view whose identity it could not match to the previous frame — losing scroll position, restarting transitions, and dropping focus in the process.
So identity is the thing to protect. The screen carries a uid: UUID assigned once when it is created and used as its Identifiable id, so a screen that gains a block is still the same screen. Items inside a collection are identified by the subjectRef they were grounded with, and a second item claiming a subjectRef already present is rejected as an invalid field rather than appended — which keeps ForEach identities unique without anyone generating a synthetic key.
Rebuild-based designs need hierarchical ID generation precisely because they destroy this and have to manufacture it again. Apply-based designs get it by not throwing the values away.
Nothing is tappable until the screen commits
Progressive rendering creates a window in which the screen is real enough to see and not finished enough to use. On a model-composed screen, that window is where a user taps a button attached to half an answer.
The rule is one expression at the point where the view is constructed:
ScreenView(
screen: screen,
interactionsEnabled: screen.isCommitted && !session.isStreaming
)Draft content renders, scrolls and is read normally. It does not respond to taps until the terminal record has arrived and the stream has ended. If the stream aborts, the pre-turn screen is restored and nothing the user could have interacted with was ever interactive.
What this still does not protect you from
A well-ordered stream can still assemble a wrong screen. The grammar constrains sequence, not truth. Every record can be legal, in the right order, at the right cardinality, and the comparison can still put the wrong two things side by side.
Grounding is a separate check. Whether a rendered value traces back to something a tool returned is not a question a state machine can answer, and it is one of the constraints in what a generated screen is structurally unable to do.
Identity discipline does not fix animation. Stable identity stops SwiftUI re-creating views. Whether the insertion of a block looks good is still layout and transition work.
And there is a real cost. Adding a record type means editing the grammar in two places — the server's table and the client's reducer — and the client half ships in a binary. That is the price of the client not trusting the server, and if your protocol is still changing weekly it is a poor trade. Rebuild-and-continue is genuinely easier to iterate on. It is the right choice right up until someone asks which screens your app can produce.
Frequently asked questions
How do I update a SwiftUI view as JSON streams in?
Buffer until a complete unit has arrived, decode it, and apply it to an observable state object that the view is already watching. SwiftUI re-renders from the state change. The work is not in the update mechanism — it is in deciding what a unit is, so that "complete" is a thing you can test for rather than a thing you assume.
How do you stop a streaming SwiftUI view from flickering?
Preserve identity. Flicker is SwiftUI re-creating a view because it could not match the new one to the old one, so give every element a stable identifier that survives updates and never rebuild a collection from scratch when you meant to append to it. Throttling hides the symptom; identity removes the cause.
What happens if records arrive out of order?
That depends on whether your protocol has states. With an explicit transition table, a record that is not legal in the current state is rejected, and the screen is unchanged — out-of-order arrival becomes a defined outcome. Without one, the record is applied wherever it lands, and the resulting screen is whatever the sequence happened to produce.
Should I rebuild the whole view or apply each update?
Rebuild if your units are not complete when they arrive, because there is nothing coherent to apply. Apply if each unit is complete and validated: it costs less per update, preserves identity for free, and makes rejection a no-op. The format decision therefore comes first — apply is only available if you chose a delimited wire.
Where to start
Take your streaming path and ask what the state is between two chunks. If the honest answer is "whatever the last rebuild produced", the ordering rules in your protocol are a document rather than a mechanism, and the fastest way to find out is to feed it a record out of order and see what renders.
If you would rather start from a protocol that already has states, the shipped stream grammar and wire schema are what we would show you first — a transition table, 23 record types, and a reducer that rolls back by doing nothing. Email hello@uzori.ai and ask for them.