SwiftQA Random

How do you avoid unnecessary view updates?

This overlaps with debugging re-renders, but “avoid” is the proactive framing — techniques you apply while building, rather than diagnosing after the fact.

1. Prefer @Observable over ObservableObject — the single highest-leverage change

ObservableObject/@Published invalidates every view observing the object whenever any published property changes, regardless of which properties that view actually reads. @Observable tracks dependencies at the individual property level:

@Observable
final class ProfileViewModel {
    var name: String = ""
    var bio: String = ""
    var lastActive: Date = .now  // updates frequently
}

struct NameLabel: View {
    let viewModel: ProfileViewModel
    var body: some View {
        Text(viewModel.name)  // only re-evaluates when `name` changes, NOT when `lastActive` ticks
    }
}

2. Pass only the specific data a view needs, not the whole containing object

// Broad — re-evaluates on ANY User field change
struct ProfileHeader: View {
    let user: User
    var body: some View { Text(user.name) }
}

// Narrow — re-evaluates only when `name` itself changes
struct ProfileHeader: View {
    let name: String
    var body: some View { Text(name) }
}

This is a direct application of the narrow-interface principle applied to view parameters.

3. Extract subviews so SwiftUI can skip re-evaluating unaffected children

struct ProfileScreen: View {
    let viewModel: ProfileViewModel
    var body: some View {
        VStack {
            ProfileHeader(name: viewModel.name)      // isolated — only cares about `name`
            ActivityFeed(items: viewModel.items)      // isolated — only cares about `items`
        }
    }
}

If viewModel.items changes, ProfileHeader doesn’t need to re-evaluate at all, provided its own input is unchanged — this only works if the subviews’ parameters are narrow to begin with.

4. Equatable conformance + .equatable() for expensive views

struct ExpensiveChartView: View, Equatable {
    let dataPoints: [Double]
    static func == (lhs: Self, rhs: Self) -> Bool { lhs.dataPoints == rhs.dataPoints }
    var body: some View { /* expensive custom drawing */ }
}

ExpensiveChartView(dataPoints: viewModel.dataPoints).equatable()

Worth reserving for views whose rendering is genuinely expensive — for cheap views, the equality check adds overhead without payoff.

5. Stable, meaningful Identifiable conformance in collections

struct Item: Identifiable {
    let id: UUID  // stable across the item's lifetime
    var name: String
}
ForEach(items) { item in ItemRow(item: item) }

An unstable ID (like using array index) causes SwiftUI to lose track of which visual row corresponds to which logical item, leading to unnecessary full-row reconstruction and @State resets.

6. Scope @Environment/@EnvironmentObject narrowly

Since environment values propagate to an entire subtree, a broadly-scoped environment object with frequently-changing properties re-evaluates every descendant reading it — even ones that only care about one small piece. Splitting into more granular environment values limits the blast radius.

7. Avoid unstable closures/computed values recreated every body evaluation

// Recreated every time the parent body evaluates — can defeat some diffing optimizations
var body: some View {
    ChildView(onTap: { viewModel.handleTap() })
}

In performance-sensitive or deeply nested scenarios, keeping closures stable (defined once, not reconstructed inline) can reduce redundant diffing work — a more marginal optimization.

8. Avoid observing more than you display — the general principle underlying most of the above

If a view holds a reference to an object/collection significantly larger or more volatile than what it actually renders, it pays an invalidation cost proportional to that whole object’s change rate, not its own actual display needs. Narrow what a view reads, and SwiftUI’s own dependency tracking does most of the optimization work automatically.

9. Measure before optimizing — same discipline as everywhere else

Body re-evaluation is cheap by design, and chasing every extra evaluation without confirming it costs anything measurable is wasted effort. Use Self._printChanges() to understand what’s triggering evaluation, and Instruments to confirm a given pattern is actually costing dropped frames before investing effort in restructuring.

Interview-ready summary

TechniqueWhat it prevents
@Observable over ObservableObjectWhole-object invalidation on unrelated property changes
Pass specific fields, not whole objectsRe-evaluation on fields the view doesn’t even read
Extract subviews with narrow inputsUnaffected children re-evaluating alongside a changed parent
Equatable/.equatable()Redundant expensive rendering when content is provably unchanged
Stable Identifiable IDsUnnecessary reconstruction/@State resets in collections
Narrow @Environment scopeBroad subtree invalidation from a single environment change
Stable closures where it mattersRedundant diffing in deeply nested/perf-sensitive views
Measure with _printChanges()/InstrumentsWasted optimization effort on changes that don’t actually cost anything

One-liner if pressed: “Almost every technique here reduces to the same idea — give each view the smallest possible dependency surface, so SwiftUI’s own diffing has less to invalidate: prefer @Observable’s field-level tracking over ObservableObject’s whole-object invalidation, pass specific fields rather than whole objects, extract subviews with narrow inputs so unaffected children skip re-evaluation, and use Equatable/.equatable() for genuinely expensive views — but I’d confirm with Self._printChanges() and Instruments that a given re-evaluation is actually costing measurable render time before spending effort optimizing it.”