DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI View Diffing & Reconciliation

SwiftUI doesn’t “redraw the screen”.

It diffs view trees.

If you don’t understand how SwiftUI decides what changed vs what stayed the same, you’ll see:

  • unnecessary re-renders
  • list animations breaking
  • views flashing or resetting
  • state jumping between rows
  • performance degrading over time

This post explains how SwiftUI reconciles view trees, how diffing works, and how to write SwiftUI that updates only what needs to change.


🧠 The Core Idea: SwiftUI Is a Tree Diff Engine

Every time state changes, SwiftUI:

  1. Recomputes body
  2. Builds a new view tree
  3. Diffs it against the previous tree
  4. Updates only the differences

SwiftUI does not mutate views directly.

It replaces parts of the tree.


🌳 What Is a View Tree?

This code:

VStack {
    Text("Title")
    Button("Tap") { }
}
Enter fullscreen mode Exit fullscreen mode

Becomes a tree like:

VStack
 ├─ Text
 └─ Button
Enter fullscreen mode Exit fullscreen mode

Every update builds a new tree.

Diffing decides which nodes are:

  • reused
  • updated
  • replaced
  • removed

🆔 Identity Is the Key to Diffing

SwiftUI matches nodes using identity.

Identity is determined by:

  • view type
  • position in hierarchy
  • explicit id()

If identity matches → node is reused
If identity differs → node is replaced


⚠️ The Most Common Diffing Bug

ForEach(items) { item in
    Row(item: item)
}
Enter fullscreen mode Exit fullscreen mode

If item.id:

  • changes
  • is derived
  • is generated dynamically

SwiftUI cannot match rows correctly.

Result:

  • wrong animations
  • state jumps rows
  • flickering
  • performance issues

Always use stable IDs.

ForEach(items, id: \.id) { item in
    Row(item: item)
}
Enter fullscreen mode Exit fullscreen mode

🔥 Why id() Forces Reconciliation

Text(title)
    .id(title)
Enter fullscreen mode Exit fullscreen mode

When title changes:

  • SwiftUI treats this as a new node
  • old node is removed
  • new node is inserted

That means:

  • all state resets
  • animations restart
  • layout recalculates

This is not a bug — it’s explicit diff control.


🧱 Structural Changes vs Value Changes

Value change:

Text(count.description)
Enter fullscreen mode Exit fullscreen mode
  • same node
  • only text updates

Structural change:

if count > 0 {
    Text("Visible")
}
Enter fullscreen mode Exit fullscreen mode

When condition flips:

  • node is removed or inserted
  • identity changes
  • animations trigger
  • state resets

Structural changes are expensive compared to value updates.


🧵 Conditional Views & Diffing

Bad pattern:

if loading {
    ProgressView()
} else {
    ContentView()
}
Enter fullscreen mode Exit fullscreen mode

These are different trees.

Better:

ZStack {
    ContentView()
    if loading {
        ProgressView()
    }
}
Enter fullscreen mode Exit fullscreen mode

This preserves identity and minimizes reconciliation.


📦 ViewModel Recreation & Diffing

MyView(viewModel: ViewModel()) // ❌
Enter fullscreen mode Exit fullscreen mode

SwiftUI sees:

  • new parameter
  • new identity
  • new subtree

Correct:

@StateObject var viewModel = ViewModel()
Enter fullscreen mode Exit fullscreen mode

ViewModel identity stability = predictable diffing.


⚖️ Equatable Views & Diff Short-Circuiting

SwiftUI can skip updates if a view is Equatable.

struct Row: View, Equatable {
    let model: Model

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.model.id == rhs.model.id &&
        lhs.model.value == rhs.model.value
    }
}
Enter fullscreen mode Exit fullscreen mode

If equal:

  • SwiftUI skips reconciliation
  • no layout
  • no redraw

Use for:

  • heavy rows
  • dashboards
  • frequently updating parents

📐 Layout Is Re-Evaluated During Diffing

Even reused nodes:

  • may re-layout
  • may re-measure
  • may re-render

Avoid:

  • GeometryReader in lists
  • deep nested stacks
  • layout work inside body

Efficient diffing depends on efficient layout.


🔄 Animation Is Diff-Driven

Animations happen when SwiftUI sees:

  • insertion
  • removal
  • movement
  • value interpolation

Bad identity → broken animations
Good identity → smooth transitions

If an animation looks wrong:
👉 the diffing model is confused.


🧪 Debugging Diffing Problems

Ask:

  1. Did identity change?
  2. Did the structure change?
  3. Did a conditional flip?
  4. Did a parent recreate?
  5. Did id() change?
  6. Did ordering change?

Diff bugs are deterministic — once you find identity issues, they disappear.


🧠 Mental Model Cheat Sheet

  • SwiftUI builds trees
  • Trees are diffed
  • Identity controls reuse
  • Structure changes cause reconciliation
  • Value changes update in place
  • Stable identity = performance + correct UI

🚀 Final Thoughts

SwiftUI is not slow.

Broken diffing makes it look slow.

Once you understand:

  • identity
  • reconciliation
  • tree structure
  • diff boundaries

You can build:

  • large lists
  • complex animations
  • dynamic UIs
  • scalable architectures

Top comments (0)