SwiftUI Data Flow
Nearly every confusing SwiftUI bug — state that resets, animations that crossfade instead of
move, lists that flash, bodies that run constantly — traces to identity, lifetime, or
dependencies. This is Apple's own mental model (the Demystify sessions + Data Essentials +
Observation), current through the WWDC26 @State macro.
When This Skill Activates
- "@State resets when…" / state loses its value on a condition change
- Views re-render too often; animations crossfade when they should move
- Lists flashing, rows reordering wrongly,
ForEach misbehaving
- Choosing between
@State, @Binding, @Bindable, @Environment, plain property
- Debugging with
Self._printChanges(); concurrency warnings in view code
Identity: the root concept
SwiftUI sees three things: identity, lifetime, dependencies. Views with the same identity
are "different states of the same conceptual UI element"; distinct identities are distinct views.
Structural identity = type + position in the hierarchy. An if/else creates two
identities (_ConditionalContent) — flipping the branch destroys/recreates the view: state
resets, transitions crossfade instead of animating.
Explicit identity = id: in ForEach or .id(_:) (also the target for
ScrollViewReader.scrollTo). Changing an explicit id is a new identity — new lifetime,
fresh state. (That's the .id(item.id) force-refresh trick — use it knowingly.)
The inert-modifier rule (the most under-used fix): prefer one view whose modifiers vary
over branching —
// ❌ two identities; state resets, transition crossfades
if expired { content.opacity(0.3) } else { content }
// ✅ one identity; cheap, pruned when inert
content.opacity(expired ? 0.3 : 1.0)
Inert values (opacity 1, padding 0) cost nothing. "By default, try to preserve identity."
Conditionally include a view inside a stack rather than conditionally wrapping the stack.
Lifetime: state is tied to identity
- View values are ephemeral — created for comparison, then destroyed. Never rely on the
struct instance; identity provides continuity.
- "Whenever the identity changes, the state is replaced" —
@State/@StateObject storage
tears down and reinitializes. If state "randomly resets," find the identity change.
- WWDC26:
@State is a macro with lazy initialization of @Observable classes (backported
to iOS 17) — the stored object initializes once per lifetime, not on every view-value init.
Remove default values when also assigning in init (source-breaking edge).
ForEach identifier rules (the flashing-list checklist)
- Stable — never
var id = UUID() computed per access (everything flashes/reanimates).
- Not indices — insert-at-front reads as insert-at-end; rows animate wrongly.
- Unique — duplicate IDs drop rows.
- Use persistent/database-derived IDs; that's what
Identifiable is for. Range ForEach
(0..<n) only with a constant range.
- Constant views per element: an
if filter inside ForEach (0-or-1 views) or AnyView
forces List to resolve every row just to count them. Filter in the data, and cache the
filtered collection in the model — an inline .filter re-runs linearly on every body.
- List/Table gather all identifiers eagerly — cheap IDs = fast loads.
Dependencies: the graph, not the tree
- Every piece of data read in body is a dependency; only views whose dependency changed
re-run, and value comparison prunes unchanged subtrees. Stable identity is "the backbone of
the dependency graph."
- Scope dependencies tightly: pass the subview what it renders (the
Image, not the whole
model). Extracting subviews is free — "breaking up one view into multiple doesn't hurt
performance" — and shrinks invalidation scope.
- Observation (
@Observable) tracks per property, per instance — a view re-renders only
when a property it actually read changes, including through computed properties, arrays,
optionals, and nesting.
- Migration from
ObservableObject: drop conformance + @Published → @Observable;
@ObservedObject → delete or @Bindable; @EnvironmentObject → @Environment.
Invalidation narrows from whole-object to read-properties — a free performance win.
State ownership: the decision rules
Ask Apple's three questions: what data does the view need · how does it manipulate it ·
where does truth live?
| Situation |
Use |
| Display-only, parent owns it |
plain let property |
| Transient, view-local UI state |
@State (group related fields into one struct with mutating methods) |
| Write access to someone else's truth |
@Binding (bindings compose: $config.note) |
| Observable model owned by this view |
@State (lazy-init since WWDC26 macro) |
Observable model, needs $model.field bindings only |
@Bindable |
| Observable model, globally available |
@Environment |
| Observable model, none of the above |
plain property |
- ❌ Never allocate a reference-type model inline as an
@ObservedObject default — every
re-run reallocates it (heap churn, data loss); use @StateObject or @State + @Observable.
- ❌ Two siblings each holding
@State for the same value desync — lift state to the container
and hand children Bindings.
@SceneStorage (restoration state, per window) and @AppStorage (settings) are stores
next to your model, not the model. Limit total sources of truth.
Body discipline
- Body must be a pure function, free of side effects — no allocation, I/O, filtering, or
string-building; move loading to
.task { await … }.
- Debug why body ran with
Self._printChanges() (or expression Self._printChanges() at an
LLDB breakpoint): @self = view value changed; a named property = that dependency changed.
Debug-only — never ship it. Deeper workflow: performance/swiftui-debugging.
- ❌
AnyView hides structure from SwiftUI (worse diagnostics/performance) — use
@ViewBuilder helpers and switch instead.
Concurrency contract (WWDC25)
View is @MainActor: body, @State, members, and Task { } created in body are all
main-actor — most view code needs zero annotations (and Swift 6.2's default-isolation mode
removes the rest).
- SwiftUI runs some of your closures off-main —
Shape.path(in:), Layout methods,
visualEffect, onGeometryChange — that's why they're Sendable. Don't touch
self.someState there; copy the value in the capture list ([pulse]) and compute from
the proxies SwiftUI hands you.
- Every
await can resume after the frame deadline, so time-sensitive state (gesture/scroll
reactions, button loading indicators) must mutate synchronously before starting async work.
Bridge UI↔async through a piece of state; keep view Tasks minimal ("inform the model") so
async logic stays unit-testable.
Output Format
Data-flow review: Symptom | Root cause (identity / lifetime / dependency / ownership) | Fix
— check identity first; it explains most of the rest.
References
1---2name: data-flow3description: SwiftUI's actual mental model — view identity, lifetime, and dependencies (the Demystify canon), state ownership decision rules, Observation's per-property tracking, body-performance discipline, and the main-actor concurrency contract. Use when state resets mysteriously, views re-render too often, animations glitch between branches, choosing @State vs @Bindable vs plain property, or debugging "why did body run."4---5
6# SwiftUI Data Flow
7
8Nearly every confusing SwiftUI bug — state that resets, animations that crossfade instead of
9move, lists that flash, bodies that run constantly — traces to identity, lifetime, or
10dependencies. This is Apple's own mental model (the Demystify sessions + Data Essentials +
11Observation), current through the WWDC26 `@State` macro.
12
13## When This Skill Activates
14
15- "@State resets when…" / state loses its value on a condition change
16- Views re-render too often; animations crossfade when they should move
17- Lists flashing, rows reordering wrongly, `ForEach` misbehaving
18- Choosing between `@State`, `@Binding`, `@Bindable`, `@Environment`, plain property
19- Debugging with `Self._printChanges()`; concurrency warnings in view code
20
21## Identity: the root concept
22
23SwiftUI sees three things: **identity, lifetime, dependencies**. Views with the same identity
24are "different states of the same conceptual UI element"; distinct identities are distinct views.
25
26- **Structural identity** = type + position in the hierarchy. An `if/else` creates **two
27 identities** (`_ConditionalContent`) — flipping the branch destroys/recreates the view: state
28 resets, transitions crossfade instead of animating.
29- **Explicit identity** = `id:` in ForEach or `.id(_:)` (also the target for
30 `ScrollViewReader.scrollTo`). Changing an explicit id is a new identity — new lifetime,
31 fresh state. (That's the `.id(item.id)` force-refresh trick — use it knowingly.)
32- **The inert-modifier rule** (the most under-used fix): prefer one view whose modifiers vary
33 over branching —
34
35 ```swift
36 // ❌ two identities; state resets, transition crossfades
37 if expired { content.opacity(0.3) } else { content }
38 // ✅ one identity; cheap, pruned when inert
39 content.opacity(expired ? 0.3 : 1.0)
40 ```
41
42 Inert values (opacity 1, padding 0) cost nothing. "By default, try to preserve identity."
43- Conditionally include a view *inside* a stack rather than conditionally wrapping the stack.
44
45## Lifetime: state is tied to identity
46
47- View **values** are ephemeral — created for comparison, then destroyed. Never rely on the
48 struct instance; identity provides continuity.
49- "Whenever the identity changes, the state is replaced" — `@State`/`@StateObject` storage
50 tears down and reinitializes. If state "randomly resets," find the identity change.
51- WWDC26: `@State` is a macro with **lazy initialization of `@Observable` classes** (backported
52 to iOS 17) — the stored object initializes once per lifetime, not on every view-value init.
53 Remove default values when also assigning in `init` (source-breaking edge).
54
55## ForEach identifier rules (the flashing-list checklist)
56
57- **Stable** — never `var id = UUID()` computed per access (everything flashes/reanimates).
58- **Not indices** — insert-at-front reads as insert-at-end; rows animate wrongly.
59- **Unique** — duplicate IDs drop rows.
60- Use persistent/database-derived IDs; that's what `Identifiable` is for. Range ForEach
61 (`0..<n`) only with a constant range.
62- **Constant views per element**: an `if` filter inside ForEach (0-or-1 views) or `AnyView`
63 forces List to resolve every row just to count them. Filter in the **data**, and cache the
64 filtered collection in the model — an inline `.filter` re-runs linearly on every body.
65- List/Table gather all identifiers **eagerly** — cheap IDs = fast loads.
66
67## Dependencies: the graph, not the tree
68
69- Every piece of data read in body is a dependency; only views whose dependency changed
70 re-run, and value comparison prunes unchanged subtrees. Stable identity is "the backbone of
71 the dependency graph."
72- **Scope dependencies tightly**: pass the subview what it renders (the `Image`, not the whole
73 model). Extracting subviews is free — "breaking up one view into multiple doesn't hurt
74 performance" — and shrinks invalidation scope.
75- **Observation** (`@Observable`) tracks **per property, per instance** — a view re-renders only
76 when a property it actually *read* changes, including through computed properties, arrays,
77 optionals, and nesting.
78- Migration from `ObservableObject`: drop conformance + `@Published` → `@Observable`;
79 `@ObservedObject` → delete or `@Bindable`; `@EnvironmentObject` → `@Environment`.
80 Invalidation narrows from whole-object to read-properties — a free performance win.
81
82## State ownership: the decision rules
83
84Ask Apple's three questions: what data does the view need · how does it manipulate it ·
85**where does truth live?**
86
87| Situation | Use |
88|---|---|
89| Display-only, parent owns it | plain `let` property |
90| Transient, view-local UI state | `@State` (group related fields into one struct with mutating methods) |
91| Write access to someone else's truth | `@Binding` (bindings compose: `$config.note`) |
92| Observable model owned by this view | `@State` (lazy-init since WWDC26 macro) |
93| Observable model, needs `$model.field` bindings only | `@Bindable` |
94| Observable model, globally available | `@Environment` |
95| Observable model, none of the above | plain property |
96
97- ❌ Never allocate a reference-type model inline as an `@ObservedObject` default — every
98 re-run reallocates it (heap churn, data loss); use `@StateObject` or `@State` + `@Observable`.
99- ❌ Two siblings each holding `@State` for the same value desync — lift state to the container
100 and hand children Bindings.
101- `@SceneStorage` (restoration state, per window) and `@AppStorage` (settings) are stores
102 *next to* your model, not the model. Limit total sources of truth.
103
104## Body discipline
105
106- Body must be a pure function, free of side effects — no allocation, I/O, filtering, or
107 string-building; move loading to `.task { await … }`.
108- Debug why body ran with `Self._printChanges()` (or `expression Self._printChanges()` at an
109 LLDB breakpoint): `@self` = view value changed; a named property = that dependency changed.
110 Debug-only — never ship it. Deeper workflow: `performance/swiftui-debugging`.
111- ❌ `AnyView` hides structure from SwiftUI (worse diagnostics/performance) — use
112 `@ViewBuilder` helpers and `switch` instead.
113
114## Concurrency contract (WWDC25)
115
116- `View` is `@MainActor`: body, `@State`, members, and `Task { }` created in body are all
117 main-actor — most view code needs zero annotations (and Swift 6.2's default-isolation mode
118 removes the rest).
119- SwiftUI runs some of *your* closures off-main — `Shape.path(in:)`, `Layout` methods,
120 `visualEffect`, `onGeometryChange` — that's why they're `Sendable`. Don't touch
121 `self.someState` there; **copy the value in the capture list** (`[pulse]`) and compute from
122 the proxies SwiftUI hands you.
123- Every `await` can resume after the frame deadline, so time-sensitive state (gesture/scroll
124 reactions, button loading indicators) must mutate synchronously *before* starting async work.
125 Bridge UI↔async through a piece of state; keep view `Task`s minimal ("inform the model") so
126 async logic stays unit-testable.
127
128## Output Format
129
130Data-flow review: `Symptom | Root cause (identity / lifetime / dependency / ownership) | Fix`
131— check identity first; it explains most of the rest.
132
133## References
134
135- https://developer.apple.com/videos/play/wwdc2021/10022/ (Demystify SwiftUI — the canon)
136- https://developer.apple.com/videos/play/wwdc2020/10040/ (Data Essentials)
137- https://developer.apple.com/videos/play/wwdc2023/10149/ (Discover Observation)
138- https://developer.apple.com/videos/play/wwdc2023/10160/ (Demystify SwiftUI performance)
139- https://developer.apple.com/videos/play/wwdc2025/266/ (Explore concurrency in SwiftUI)
140- Related skills: `performance/swiftui-debugging` (Instruments workflow), `swiftui/layout`, `swift/concurrency-patterns`, `ios/coding-best-practices`