How SwiftUI Decides What to Redraw
An appendix to SwiftUI Is Not React
The framework is watching. It just has better things to do than redraw everything.
Article 05 covered what Renala draws and why: state ownership, sidebar virtualization, Canvas rendering, accessibility overlays, navigation. This appendix covers the other half: how SwiftUI decides what not to draw. Understanding the update model changes how you structure code, and in a treemap app with tens of thousands of rectangles, it changes whether the app stays responsive.
The difference between a responsive treemap and a sluggish one is often not what you compute, but what you accidentally ask the framework to recompute. SwiftUI is selective about what it redraws. The question is whether your code lets it be.
The React default: parent renders cascade
React’s re-render model is explicit and predictable. When state changes in a component, React calls that component’s function again. Then it calls every child component’s function. Then every grandchild. All the way down.
This happens regardless of whether props changed. A parent re-renders, its children re-render. Period.
React.memo is the opt-in escape. Wrap a component in React.memo and React will compare the new props against the old ones. If they are shallowly equal, the component skips its render. Without that wrapper, prop comparison never happens.
useMemo is a separate concern entirely. It memoizes a value inside a render. It does not prevent the component from re-rendering. You can use useMemo to produce a referentially stable object that helps a React.memo child downstream avoid its own re-render, but useMemo alone has no effect on whether the component that calls it runs again.1
The mental model: everything re-runs unless you say otherwise. The developer does the bookkeeping. React does the DOM diffing.
React’s model is transparent: everything re-runs, and you carve out exceptions. SwiftUI inverts this. It tries to skip as much as possible, but only when it can prove that nothing changed. What counts as proof depends on what the framework can see.
The SwiftUI default: track the reads, skip the rest
SwiftUI shifts the default. When a parent’s body runs, it still creates child view structs, but SwiftUI can skip calling a child’s body when the child’s stored properties compare equal to last time. On top of that, @Observable tracks which properties each view actually reads during body evaluation, so later mutations only invalidate the views that read the changed property.
The mechanism behind this is @Observable (introduced at WWDC 2023). When SwiftUI evaluates a view’s body, it records every property access on @Observable objects. Think of it as a spreadsheet: SwiftUI only recalculates cells whose inputs changed. The tracking is automatic, no formulas to declare. If ViewA reads viewModel.scanState and ViewB reads viewModel.currentRoot, a mutation to scanState invalidates ViewA but not ViewB. No subscription calls. No dependency arrays. No selector functions.
This is finer-grained than anything React offers out of the box. The closest React analogue is useSelector in Redux or a MobX observer, but those require explicit wiring. SwiftUI automates it.
Identity: structural and explicit
Tracking dependencies is half the story. The other half is identity: how SwiftUI knows that this SidebarRow in the current evaluation is the same SidebarRow from the previous one.
Identity matters because it drives the decision to diff or to replace. When SwiftUI recognizes a view as the same one from last time, it compares the view struct’s stored properties against the previous values. If they have not changed, it can skip body evaluation entirely. If the view is new (new identity), there is nothing to compare against, so body runs unconditionally.
SwiftUI uses two kinds of identity:
- Structural identity: the view’s position in the
bodyhierarchy. The firstTextin anHStackis identified by being the firstTextin thatHStack. If the hierarchy changes shape (a conditional branch flips), SwiftUI may treat the view as new and discard its state. - Explicit identity: provided by
ForEach(items, id: \.someProperty)or the.id()modifier. This is how SwiftUI tracks items in a list or distinguishes views that might move around.
The WWDC 2021 session Demystify SwiftUI is the best public reference for how identity and dependencies drive updates. Apple does not fully document the internal comparison algorithm, but the session lays out the model clearly enough to reason about performance.
So SwiftUI tracks dependencies and compares inputs to decide what to skip. Both mechanisms rely on the framework being able to see the inputs. Closures are where that visibility ends.
Where closures break the model
In Renala, scan progress updates were triggering re-evaluation of thousands of sidebar rows whose data had not changed. The cause: every row received a fresh closure for its context menu action, and SwiftUI could not tell the new closure from the old one.
A closure in Swift is a reference type. A freshly allocated closure is a new reference. SwiftUI cannot look inside a closure to determine functional equivalence. It sees different inputs and assumes the worst.
Consider a parent view that passes an action closure to a child:
// Parent creates a fresh closure on every body evaluation
ChildView(onTap: { viewModel.handleTap(item) })
Every time the parent’s body runs, it creates a new closure. When SwiftUI compares the child’s inputs, the old closure and the new closure are different objects, even if they capture the same variables and do the same thing. SwiftUI sees different inputs and reevaluates the child.
In isolation, one unnecessary reevaluation is harmless. In a sidebar with thousands of visible rows, the overhead compounds fast.
The fix: stable ViewModel references
The fix in Renala was simple: pass the ViewModel reference, not closures derived from it.
// Before: fresh closure every time parent body runs
SidebarRow(node: node, onSelect: { viewModel.select(node) })
// After: stable reference, child calls methods directly
SidebarRow(node: node, viewModel: viewModel)
The ViewModel is a class (a reference type in Swift, like an object in JavaScript). References to the same instance are referentially equal across evaluations. When SwiftUI compares the child’s stored properties, the ViewModel reference has not changed, so the framework has a better chance of skipping the reevaluation. (The exact comparison rules are not public API; treat this as observed behavior consistent with what Apple demonstrates in Demystify SwiftUI, not a documented contract.)
@Observable still tracks which properties the child reads inside its body. Passing the whole ViewModel does not mean the child reacts to every mutation on it. It reacts only to the properties it actually reads. The child stays tied to the specific state it uses while receiving a stable input that helps SwiftUI’s comparison logic.
This is a pattern to measure, not a guaranteed optimization. Profile with Instruments before and after. In Renala, the difference was measurable in the sidebar.
SwiftUI’s update model rewards code that gives the framework stable, comparable inputs. Closures are invisible; references are not. Identity and dependency tracking do the rest, but only when the inputs cooperate. In a treemap app rendering tens of thousands of nodes, the difference between “skip” and “redraw” is the difference between fluid and frozen.
Aside: Canvas and @Environment
Canvas introduces its own constraint worth noting: @Environment cannot be declared inside a Canvas closure. @Environment is a property wrapper, and property wrappers only apply to stored properties of types, not local variables in closures. The workaround is to declare @Environment on the enclosing view and let the closure capture it, or use context.environment.colorScheme from the GraphicsContext passed to the closure.
Going further
- Demystify SwiftUI (WWDC 2021): the primary reference for identity, lifetime, and dependencies.
- Discover Observation in SwiftUI (WWDC 2023):
@Observableand property-level tracking. - SwiftUI documentation: the official reference.
Footnotes
-
A common confusion:
React.memoprevents re-rendering by comparing props.useMemomemoizes a value inside a render but does not prevent the render itself. They complement each other but serve different purposes. ↩
