SwiftUI View Invalidation

December 14, 2025

View Invalidation

In SwiftUI, "re-drawing", "invalidating", or "updating" a View typically refers to two things:

  1. The body property of the View will be computed, then diffed

  2. If the View is a UIViewRepresentable or UIViewControllerRepresentable, the update and sizeThatFits functions will be invoked.

Both of these can be expensive:

  • Computing a large deeply nested body property can take some time, especially if done frequently. This can also cause cascading invalidations throughout the View tree.

  • Updating a leaf view (e.g a UIViewRepresentable) can also be expensive, as well as the sizeThatFits method (e.g, it might call out to systemLayoutSizeFitting multiple times)

We obviously want to minimize these invalidations in our code.

How does SwiftUI decide to "evaluate" a view?

MyView(x: x) // When will `MyView` change?

To decide if a View should be "invalidated", SwiftUI compares the properties on the view struct.

SwiftUI doesn't care what properties are actually used — it just compares the properties that are defined.

For example, our view below will be "re-evaluated" every time it's compared, because the x property will always be different — even though the x property is not even referenced.

struct MyView: View {
    let x = UUID()

    var body: some View { EmptyView() }
}

How are properties compared?

Here is a pseudo table describing how property types are compared:

Property TypeMethod
Equatable conforming type

SwiftUI compares the values directly using their Equatable function implementation.

Primitive value type

These primitive value types should conform to Equatable already — e.g, String, Int, Bool, etc. They should be compared.

structCompares each stored property recursively
AnyObject

SwiftUI uses the pointer, e.g something like ObjectIdentifier or ===

ClosureSpecial handling!

A few learnings here:

  • If you make a non-equatable property always equatable — it will never reload/redraw your view when its changed

  • Reference types (e.g @Observable macro classes) are great as comparing their properties will always be considered the same (===) — yet it can be used to locally re-draw specific views (I will explain soon).

How closures work

Above, I mentioned closures have special handling. Here is how they work:

Closures that capture state

If your closure captures a tracked state property (e.g., @State, or a property from an @Observable macro, etc.) — the View will ALWAYS reload — even if the state property hasn't changed:

Here, MyView will always be "updated" / "invalidated" every time the body of the parent runs, because its escaping closure references a state property.

Intuitively, you'd expect MyView to only reload when the count property actually has changed — but this is not the case.

This means, any time you have an escaping closure that captures a state tracked property, it can have huge negative performance impact:

@State private var count = 0

var body: some View {
    MyView {
        print(count)
    }
}

Closures that don't capture state

If your closure doesn't capture any @State or @Observable macro class property, etc — the closure will always be considered the "same", and it will essentially never be compared when diffing the views.

@State private var count = 0

var body: some View {
    MyView {
        print("Hello world")
    }
}

Avoiding large invalidations

For sake of example — say we have a large SwiftUI views in a body property:

struct MyView: View {
    @State private var count: Int = 0

    var body: some View {
        Text("Count: \(count)").onTapGesture { count += 1 }
        MyRow("1")
        MyRow("2")
        // ...
        MyRow("N")
    }
}

Anytime the count property changes, it will cause the body to be recomputed.

This will cause every single view (e.g MyRow) to be compared on each tap change.

Instead, we can use reference types to break-up the invalidation (as reference types are always equal ===):

@Observable
@MainActor
class ViewModel {
    var count: Int = 0
}

struct MyView: View {
    @State private var viewModel = ViewModel()

    var body: some View {
        Counter(viewModel: viewModel)
        MyRow("1")
        MyRow("2")
        // ...
        MyRow("N")
    }
}

struct Counter: View {
    let viewModel: ViewModel

    var body: some View {
        Text("Count: \(viewModel.count)").onTapGesture { viewModel.count += 1 }
    }
}

Here, we can see when the view model's count changes — it only invalidates the body of the Counter view — not the body of the MyView.

Furthermore, if the body of MyView was ever invalidated — when Counter is compared, it will never be "invalidated" / "re-drawn" as the viewModel property would simply be compared viewModel === viewModel and thus there is no change.

Conclusion

What I found most interesting from this, is that:

  • Closures that track state always re-invalidate a view
  • We can use reference types strategically to design how our view trees will be invalidated