NEW BOOK! Swift Charts Beyond the Basics: A practical reference for building advanced data visualizations. Learn more ...NEW BOOK! Swift Charts Beyond the Basics:Build advanced data visualizations. Learn more...

SwiftUI data dependencies and their effect on view updates

A SwiftUI view can receive the data it needs in many forms. We can pass values through stored properties, keep local state, accept bindings, read values from the environment or use an observable model. These declarations all connect data to a view, but they do not all become dependencies in the same way or have the same effect on view updates.

In this post, we will compare these different types of data dependencies and see why some changes cause a view's body to run again while others do not. We will also look at cases where a value appears unchanged from the app's perspective but still leads to another body evaluation.

# Plain stored properties

Regular stored properties are the most direct form of data dependency in a SwiftUI view. Values passed when the view is initialized become part of the view value. When a parent produces a new instance of the view, SwiftUI compares its stored inputs with the previous instance to determine whether the body needs to be evaluated again.

# Value types

For value-type inputs, SwiftUI compares the values stored in one instance of the view with those in the next. This comparison extends through a value's stored properties, so it applies both to types such as String and Int and to custom structures composed of multiple values.

In the example below, both birdName and sightingCount are value-type inputs. If either value changes, SwiftUI reevaluates the BirdDetailsView body.

struct BirdDetailsView: View {
    let birdName: String
    let sightingCount: Int

    var body: some View {
        VStack {
            Image(birdName)
            Text("Bird: \(birdName)")
            Text("Number of sightings: \(sightingCount)")
        }
    }
}

Plain stored properties remain part of a view's inputs regardless of whether body accesses them. SwiftUI considers all stored values when comparing successive instances of a view. As a result, changing an unused input can still cause the view's body to be reevaluated.

For example, we could move the text that displays the sighting count from BirdDetailsView into its parent but forget to remove sightingCount from the child view's inputs.

struct BirdDetailsView: View {
    let birdName: String

    // This property is not used in body, but SwiftUI still compares it
    // when the parent creates a new BirdDetailsView value.
    let sightingCount: Int

    var body: some View {
        VStack {
            Image(birdName)
            Text("Bird: \(birdName)")
        }
    }
}

struct BirdSightingsView: View {
    @State private var sightingCount = 0

    private let birdName = "Fantail"

    var body: some View {
        VStack {
            BirdDetailsView(
                birdName: birdName,
                sightingCount: sightingCount
            )
            
            Text("Number of sightings: \(sightingCount)")
            
            Spacer()

            Button("Add sighting") {
                // The state update produces a new BirdDetailsView value with
                // a different sightingCount input, so its body runs again.
                sightingCount += 1
            }
        }
        .padding()
    }
}

Incrementing the count will create a BirdDetailsView with a different stored value, so its body will run again even though it no longer reads sightingCount.

When passing value types into a view, we should only include the values it needs to produce its content. Otherwise, a change to an unused input can make the view's body run again without affecting the resulting interface.

# Reference types

Unlike value types, reference-type inputs are not compared by examining the values stored on the instance. SwiftUI checks whether the view received the same class instance as before. A regular stored input only appears different when the parent passes another instance.

For example, we can store the bird data in a plain class and replace the instance when selecting a different bird.

final class BirdRecord {
    var name: String
    var sightingCount: Int

    init(name: String, sightingCount: Int) {
        self.name = name
        self.sightingCount = sightingCount
    }
}

struct BirdDetailsView: View {
    let bird: BirdRecord

    var body: some View {
        VStack {
            Image(bird.name)
            Text("Bird: \(bird.name)")
            Text("Number of sightings: \(bird.sightingCount)")
        }
    }
}

struct BirdSightingsView: View {
    @State private var bird = BirdRecord(
        name: "Fantail",
        sightingCount: 4
    )

    var body: some View {
        VStack {
            BirdDetailsView(bird: bird)

            Spacer()

            Button("Show kea") {
                bird = BirdRecord(
                    name: "Kea",
                    sightingCount: 2
                )
            }
        }
        .padding()
    }
}

Pressing the button replaces the stored reference with a new BirdRecord instance. BirdDetailsView receives a different instance, so SwiftUI reevaluates its body.

Changes made to properties on the existing instance behave differently. Mutating a property does not change the reference stored by the view, so the input still appears unchanged to SwiftUI.

In the example below, pressing the button updates the properties on the existing BirdRecord instead of replacing it with another instance.

struct BirdSightingsView: View {
    @State private var bird = BirdRecord(
        name: "Fantail",
        sightingCount: 4
    )

    var body: some View {
        VStack {
            BirdDetailsView(bird: bird)

            Spacer()

            Button("Show kea") {
                // Mutating these properties keeps the same BirdRecord instance,
                // so BirdDetailsView's body does not run.
                bird.name = "Kea"
                bird.sightingCount = 2
            }
        }
        .padding()
    }
}

BirdDetailsView continues to display the fantail and its original sighting count because its body does not run after the properties are mutated.

Plain reference types work as view inputs when replacing the entire instance represents a change in the data. For models that change in place, we need an observation mechanism to keep the interface in sync.

# Closures

Closures can also be passed into a view through regular stored properties. Like the value and reference types we discussed, they become part of the view value SwiftUI examines when the parent produces a new instance. The difference is that SwiftUI cannot reliably determine whether two closure values are the same.

We can see the effect of this comparison behavior when a state change reevaluates a parent's body without changing any data used by one of its subviews. In the example below, BirdSightingsView owns a watchlist and passes BirdDetailsView a closure for adding the displayed bird to it. It also stores sightingCount, which is not passed to BirdDetailsView. When sightingCount changes, BirdSightingsView reevaluates its body and creates the addToWatchlist closure again, so SwiftUI treats BirdDetailsView as changed and runs its body even though none of the values used to produce its interface have changed.

struct BirdDetailsView: View {
    let birdName: String
    let addToWatchlist: () -> Void

    var body: some View {
        VStack {
            Image(birdName)
            Text("Bird: \(birdName)")

            Button(
                "Add to watchlist",
                action: addToWatchlist
            )
        }
    }
}

struct BirdSightingsView: View {
    @State private var sightingCount = 0
    @State private var watchlist: Set<String> = []

    private let birdName = "Fantail"

    var body: some View {
        VStack {
            BirdDetailsView(
                birdName: birdName,
                addToWatchlist: {
                    watchlist.formUnion([birdName])
                }
            )

            Text("Number of sightings: \(sightingCount)")
            Text("Birds in your watchlist: \(watchlist.count)")

            Spacer()

            Button("Add sighting") {
                // Updating sightingCount makes BirdDetailsView's body run again,
                // even though it does not change any values used to produce its interface.
                sightingCount += 1
            }
        }
        .padding()
    }
}

BirdDetailsView produces the same interface after this additional body evaluation because birdName has not changed. The cost may be negligible for a view this small, but it can become significant when the parent reevaluates frequently or when the subview performs more work in its body.

We should avoid passing closures to subviews when possible, and prefer inputs that SwiftUI can compare reliably.

SwiftUI Fundamentals by Natalia Panferova book coverSwiftUI Fundamentals by Natalia Panferova book cover

Deepen your understanding of SwiftUI!$35

The essential guide to SwiftUI core concepts and APIs

SwiftUI Fundamentalsby Natalia Panferova

  • Explore the key APIs and design patterns that form the foundation of SwiftUI
  • Develop a deep, practical understanding of how SwiftUI works under the hood
  • Learn from a former Apple engineer who worked on widely used SwiftUI APIs

Deepen your understanding of SwiftUI!

The essential guide to SwiftUI core concepts and APIs

SwiftUI Fundamentals by Natalia Panferova book coverSwiftUI Fundamentals by Natalia Panferova book cover

SwiftUI Fundamentals

by Natalia Panferova

$35

# @State and @Binding

The @State macro allows a view to store mutable data in storage managed by SwiftUI. The storage is associated with the view's identity, so the value persists as SwiftUI creates new instances of the view during updates.

When another view needs to modify the value, we can pass it a binding. Applying $ to the state property produces a Binding to the same storage. Another view can accept this binding through an @Binding property, which allows it to read and write the value without storing another copy.

State and bindings affect view updates differently from plain stored properties. Unlike plain stored inputs, which remain part of the view value whether or not the body reads them, state and bindings establish a dependency only when the view accesses their values during body evaluation.

Until the value is first read in body, changing it does not trigger a body evaluation. After the first access, it remains a dependency as long as the view keeps the same identity, even if subsequent body evaluations no longer use it.

This distinction becomes important when a view can modify a bound value without using it to produce its current interface. In the example below, a conditional branch lets us compare the resulting body evaluations before and after the value is first read.

struct BirdDetailsView: View {
    let birdName: String
    @Binding var sightingCount: Int

    @State private var showsSightingCount = false

    var body: some View {
        VStack {
            Image(birdName)
            Text("Bird: \(birdName)")

            Toggle(
                "Show sighting count",
                isOn: $showsSightingCount
            )

            if showsSightingCount {
                Text(
                    "Number of sightings: \(sightingCount)"
                )
            }

            Spacer()

            Button("Add sighting") {
                // Before the count has been shown, changing it does not
                // reevaluate body. After it has been accessed once, it does.
                sightingCount += 1
            }
        }
    }
}

When BirdDetailsView first evaluates its body, showsSightingCount is false, so the conditional branch does not access sightingCount. Pressing the button changes the state through the binding without running the BirdDetailsView body again.

Turning on the toggle changes showsSightingCount and runs the body. The conditional branch now accesses sightingCount and displays its latest value, including any sightings added while the count was hidden. This access establishes the dependency, so further changes to the bound value run the body and update the displayed count.

If we turn the toggle off again, the next body evaluation no longer accesses sightingCount, but the dependency established by the earlier access remains. Adding another sighting still runs the body.

Conditional access can delay when a state or a binding becomes a dependency, but it does not remove that dependency after the value has been read. Changes to the value will continue to trigger the view's body reevaluations. If the resulting body evaluations become expensive, we can move the part of the interface that reads the value into a separate view, limiting updates to that part of the hierarchy.

# @Environment and @FocusedValue

Environment values allow a view to read data supplied through its surrounding hierarchy without passing it through every initializer along the way. SwiftUI provides many built-in values, and we can define custom ones by adding an @Entry property to EnvironmentValues. A view reads a specific value by declaring an @Environment property with its key path.

Focused values provide similar access to data associated with the current focus. A view publishes a value with the focusedValue() modifier, and another view reads it by declaring a @FocusedValue property. The value comes from the focused view or its nearest ancestor that provides it, and becomes nil when no value is available for that key.

Declaring an @Environment or @FocusedValue property inside a view struct subscribes the view to updates in the corresponding value. When a new value is supplied, SwiftUI compares it with the previous one using the same rules discussed for plain stored inputs. If the comparison finds a change, SwiftUI reevaluates the view's body even when the property is not used to produce its interface.

An unused subscription can remain after the part of the interface that needed the value has been removed or moved elsewhere during a refactor. In the example below, BirdDetailsView still declares an environment property for sightingCount, but its body no longer displays the count.

extension EnvironmentValues {
    @Entry var sightingCount = 0
}

struct BirdDetailsView: View {
    let birdName: String

    @Environment(\.sightingCount) private var sightingCount

    var body: some View {
        VStack {
            Image(birdName)
            Text("Bird: \(birdName)")
        }
    }
}

struct BirdSightingsView: View {
    @State private var sightingCount = 0

    var body: some View {
        VStack {
            BirdDetailsView(birdName: "Fantail")
                .environment(\.sightingCount, sightingCount)

            Text("Number of sightings: \(sightingCount)")

            Spacer()

            Button("Add sighting") {
                // Changing the environment value runs BirdDetailsView's body,
                // even though the view does not use sightingCount.
                sightingCount += 1
            }
        }
        .padding()
    }
}

Incrementing sightingCount supplies a different environment value to BirdDetailsView. Because the view declares the corresponding @Environment property, its body runs even though the resulting interface only depends on birdName.

The same behavior applies to @FocusedValue. As focus moves, the value for a key can change or become nil. A view that declares the corresponding property will have its body reevaluated even if it does not read the focused value.

We should remove unused @Environment and @FocusedValue properties when a view no longer needs them. This removes the subscriptions and prevents changes in the environment or focus hierarchy from running a body that does not produce a different interface.

# @Observable models

Applying the @Observable macro to a class adds observation to its stored properties. A view that creates the model can keep its instance in @State, while other views can receive the same instance through a regular stored property or read it from the environment.

SwiftUI tracks the observable properties that a view reads while evaluating its body. Each read establishes a dependency on that property rather than on the model as a whole. Changing a property reevaluates the views that depend on it, while views that only read other properties on the same model remain unchanged.

Property-level tracking allows different views to depend on different parts of the same model. In the example below, BirdSightingsView reads sightingCount, while BirdDetailsView only reads name.

@Observable
final class BirdModel {
    var name: String
    var sightingCount = 0

    init(name: String) {
        self.name = name
    }
}

struct BirdDetailsView: View {
    let bird: BirdModel

    var body: some View {
        VStack {
            Image(bird.name)
            Text("Bird: \(bird.name)")
        }
    }
}

struct BirdSightingsView: View {
    @State private var bird = BirdModel(name: "Fantail")

    var body: some View {
        VStack {
            BirdDetailsView(bird: bird)

            Text(
                "Number of sightings: \(bird.sightingCount)"
            )

            Spacer()

            Button("Add sighting") {
                // BirdDetailsView does not read sightingCount,
                // so changing the count does not run its body.
                bird.sightingCount += 1
            }

            Button("Show kea") {
                // BirdDetailsView reads name, so changing the name
                // runs its body and updates the displayed bird.
                bird.name = "Kea"
            }
        }
        .padding()
    }
}

Adding a sighting changes sightingCount, which reevaluates BirdSightingsView because its body reads that property. BirdDetailsView only reads name, so its body does not run. Changing name reevaluates BirdDetailsView and updates the displayed bird without reevaluating BirdSightingsView.

Observation recalculates a view's property dependencies during every body evaluation. If an observable property is read inside a conditional branch, it becomes a dependency only when that branch runs. A later body evaluation that skips the branch removes the dependency, so subsequent changes to the property no longer reevaluate the body. This differs from state and bindings, where a dependency remains after the value has been read.

# ObservableObject property wrappers

An ObservableObject publishes changes through its objectWillChange publisher. Marking a property with @Published makes setting that property send the notification automatically.

A view subscribes to these notifications by declaring the object with one of SwiftUI's object-specific property wrappers. @StateObject creates and retains an instance for the lifetime of the view's identity, @ObservedObject observes an instance supplied to the view, and @EnvironmentObject retrieves an instance supplied through the surrounding hierarchy.

These wrappers differ in how the view obtains the object, but they all subscribe to the same object-wide change notification. When objectWillChange emits, SwiftUI reevaluates a subscribed view without considering which published properties its body reads. A change to any @Published property will run the body even when the changed property is not used to produce the interface.

We can see this subscription behavior when a view owns an object and passes it to a subview, even though its own body does not read any of the object's published properties. In the example below, BirdSightingsView stores a BirdModel in @StateObject and supplies it to BirdDetailsView. The parent only mutates sightingCount from the button action.

final class BirdModel: ObservableObject {
    @Published var name: String
    @Published var sightingCount = 0

    init(name: String) {
        self.name = name
    }
}

struct BirdDetailsView: View {
    @ObservedObject var bird: BirdModel

    var body: some View {
        VStack {
            Image(bird.name)
            Text("Bird: \(bird.name)")
        }
    }
}

struct BirdSightingsView: View {
    @StateObject private var bird = BirdModel(name: "Fantail")

    var body: some View {
        VStack {
            BirdDetailsView(bird: bird)

            Spacer()

            Button("Add sighting") {
                // Changing sightingCount runs BirdSightingsView's body,
                // even though the body does not read the property.
                bird.sightingCount += 1
            }
        }
        .padding()
    }
}

Pressing the button emits objectWillChange. BirdSightingsView reevaluates its body because it declares the model with @StateObject, even though it does not read name or sightingCount. BirdDetailsView also reevaluates because its @ObservedObject property subscribes to the same notification, even though its body only reads name.

With an @Observable model, passing the model reference to BirdDetailsView would not make BirdSightingsView depend on its properties. Changing sightingCount in the same example would reevaluate neither view because neither body reads that property.

This object-wide invalidation is why, in modern SwiftUI projects, we should prefer the newer Observation APIs over ObservableObject and its property wrappers.


If you are looking to build a strong foundation in SwiftUI, my book SwiftUI Fundamentals takes a deep dive into the framework's core principles and APIs to help you understand how it works under the hood and how to use it effectively in your projects. And my more recent book The SwiftUI Way helps you adopt recommended patterns, avoid common pitfalls, and use SwiftUI's native tools appropriately to work with the framework rather than against it.

For more resources on Swift and SwiftUI, check out my other books and book bundles.

The SwiftUI Way by Natalia Panferova book coverThe SwiftUI Way by Natalia Panferova book cover

Work with SwiftUI. Not against it.$35

A field guide to SwiftUI patterns and anti-patterns

The SwiftUI Wayby Natalia Panferova

  • Avoid common SwiftUI pitfalls
  • Build deeper intuition for the framework
  • Gain insights from a former SwiftUI Engineer at Apple

Work with SwiftUI. Not against it.

A field guide to SwiftUI patterns and anti-patterns

The SwiftUI Way by Natalia Panferova book coverThe SwiftUI Way by Natalia Panferova book cover

The SwiftUI Way

by Natalia Panferova

$35