SwiftUI rendering
SwiftUI looks at three things in your code: identity (is this the same element as
before?), lifetime (how long that identity lasts — and with it, @State), and
dependencies (which inputs, when they change, make a body run again). Almost every
"why did it re-render / lose state / animate oddly / hitch" question is one of these.
The model
- View values are ephemeral; identity is not. A view struct is created, compared with
the previous value, and thrown away. What persists is its identity, and
@State/@StateObjectstorage is tied to that identity: when identity changes, state is replaced andonAppear/taskrun again, images reload. - Two kinds of identity. Explicit:
ForEachIDs,Identifiable,.id(_:). Structural: a view's type and position in the hierarchy — each branch of anif/switchis a different identity. - The dependency graph. A view's dependencies are its stored properties (inputs from its
parent),
@State,@Environment,@Binding, and the@Observableproperties itsbodyreads. When one changes, only the views depending on it get a newbody. SwiftUI then compares the new child values with the old ones and stops wherever nothing changed. - Update order for an invalidated view: new value → dynamic properties updated →
bodyruns → children compared → recurse.
Rules
Identity
- Never identify by index (
ForEach(Array(x.enumerated()), id: \.offset),0..<countover dynamic data). Anything inserted above shifts every later index: their state resets, their images reload, animations go to the wrong place. Use a stable, unique ID. - IDs must be stable and unique. No
UUID()in a computedid(new identity every render). No names that can repeat (two "Dog biscuit" treats). Uniquify repeats (key,key#2) if the data can't guarantee it — duplicate IDs in aForEachmisbehave. - For generated or streamed content, derive identity from structure that doesn't
move: where the element starts in the source text (streaming only appends), or
template position + per-iteration key for program output (React's model:
template:key). Name unkeyed items (loose text, paragraphs) after the keyed element before them plus a counter (e812+2), so new content at the end never renames earlier items. - Prefer one view with changing modifiers over branches.
if expired { v.opacity(0.3) } else { v }is two identities;v.opacity(expired ? 0.3 : 1)is one. "Inert" values (opacity 1, padding 0,framewith no sizes,layoutPriority(0),transformEnvironment) cost ~nothing. Use a branch only for genuinely different views. - Switch layouts with
AnyLayout, notif wrap { FlowLayout { … } } else { HStack { … } }— children keep their identity and state. - Avoid
AnyView. It hides the type structure from SwiftUI (worse diffing, and inList/Tableforces building every row to count them). Use@ViewBuilderfunctions,switch, generics. - Model element kinds as an enum and switch over it in the dispatching view, with no
default: a new case can't be silently unhandled.
Dependencies
- Pass views only what they use. A view taking a whole model re-runs
bodywhen any field changes; pass the image, not the dog. Extract subviews to scope dependencies — with judgement, not for every line. @Observabletracks whatbodyreads. Reading a property in an action closure creates no dependency; reading it inbodydoes. Put state the view shows in the model and read it inbodyonly where needed.- Never put a new closure (or other non-comparable value) in the environment per
render — e.g.
.environment(\.actions, Actions { … })in abody. It can't be compared, so every reader is invalidated on every parent update. Put a stable object in the environment instead (.environment(model)with@Observable, read as@Environment(Model.self) var model: Model?), and call it from actions. - Don't notify when nothing changed. An
@Observableproperty notifies on every set. Guard:if new != value { value = new }— matters when you rebuild derived state on every chunk or tick. Equatableviews to skip unchanged subtrees. Conform the view (only possible when it has no property wrappers) and apply.equatable()where a parent re-renders often with mostly-unchanged children (a list of blocks while content streams). Write a custom==comparing only what the view shows. ⚠️.equatable()hides the child'slayoutPriorityfrom its container. Inside anHStack/row, alayoutPriority(1)child wrapped inEquatableViewstops winning spare space and the row lays out differently. Use.equatable()in vertical lists of blocks, not on children of rows/stacks that rely on priority.
Cheap body
- No parsing, decoding, filtering, sorting or allocation-heavy work in
body. It runs on every invalidation. Precompute in the model, cache (anNSCachekeyed by input), or hoist it to the view whose inputs actually change. - Split expensive inputs from fast-changing state. If a view decodes data and holds
@Statethat changes often (drag, scrub, hover), the decode re-runs on every change. Decode in a parent, pass the result to a child that owns the@State. - Slow work goes in
.task, notinitorbody. Make itasync; never block in a dynamic property's initial value. - Cache costly formatting —
AttributedString(markdown:), string interpolation on hot paths, bundle lookups.
Lists
List/Tablegather every ID up front. Keep ID generation cheap, and give eachForEachelement a constant number of views — noiffilters orAnyViewinside the row closure (filter the data, in the model, not inline inbody). NestedForEachis fine for sections.
SwiftUI in UIKit cells (UIHostingConfiguration)
- Cells are reused, and SwiftUI view state can carry into the next item shown in that
cell. Give the root
.id(item.id)so a different item gets a fresh tree — and keep user-visible state (checked, expanded, selection) in the model, keyed by stable IDs, not in@State, or it's lost/misapplied on reuse and scrolling. - Reconfiguring a cell rebuilds the root view value. If content is driven by an
observed model, make the root view
Equatableon what it shows (not on raw inputs that only feed the model) and apply.equatable(), so reconfigure-per-update doesn't re-run the whole tree — the observation does the actual update. - Self-sizing measures with an open height. Views without an ideal size collapse to
zero there:
Canvas, shapes,GeometryReader..aspectRatio(r, contentMode: .fit)is not enough — give them a size with a small customLayout(width from the proposal, height = width / ratio; ignore probing heights). .aspectRatio(nil, contentMode:)locks a view to its content's ideal size — cards stop filling their row. Apply.aspectRatioonly when you have a ratio.
Review checklist
- Every
ForEachhas stable, unique IDs — no\.offset, no computedUUID(). - No
AnyView; branches only for genuinely different views;AnyLayoutfor layout switches. - Views receive only the data they render.
- Nothing non-comparable is created per render in the environment.
-
bodydoes no parsing/decoding/filtering; expensive inputs separated from fast@State. - Observable properties aren't re-set to equal values.
-
.equatable()where it saves work — and not on children that uselayoutPriority. - Hosted cells: root
.id(item.id), visible state in the model, Equatable root. - Views without an ideal size are given one before going into self-sizing cells.
Verifying
let _ = Self._printChanges()at the top of abodylogs why it ran (@self,@identity, or the property that changed). Debug only — remove before shipping.- Instruments → SwiftUI template for body counts and slow updates; Hangs/Hitches for the effects.
- Identity test: render every prefix of streamed content and assert that every completed item's ID appears unchanged in the final render; after interactions, assert surviving elements kept their IDs; assert no duplicate sibling IDs.
- Pixel diff before/after a rendering refactor: render views offscreen on macOS with
ImageRenderer(same code,.frame(width:),proposedSizewith nil height) and compare PNGs. It catches layout regressions like theEquatableView/layoutPriorityone. (Map,ScrollViewand remote images don't render offscreen; compare everything else.) Bisect a difference by toggling one changed file at a time.
References
references/case-study.md— the AddToChatMenu reply renderer (streamed, model-generated markup in collection view cells): each problem found against these rules and its fix.- WWDC21 Demystify SwiftUI (session 10022); WWDC23 Demystify SwiftUI performance (session 10160).