iOS path · The running app · Topic 3 of 11

How SwiftUI updates views

Not started

A SwiftUI View is a small struct that describes what should be on screen. SwiftUI creates and throws these structs away constantly; the things that last are the **identity** it assigns to each view, the **state** it stores for that identity, and the **dependencies** it records while body runs. When a dependency changes, SwiftUI marks the view invalid, calls its body again on the main thread, compares the result with the last one and updates only what differs.

Why it matters: Most SwiftUI bugs (lost state, views that don't refresh, slow lists) come from misunderstanding identity and dependencies, not from the layout code.

  1. 1Views are values

    A View struct is a cheap description; SwiftUI keeps the real, long-lived state elsewhere and calls body again when it needs a new description.

  2. 2Identity decides lifetime

    State lives as long as the view's identity: its place in the hierarchy, or the id you give it.

  3. 3Reads are dependencies

    Since iOS 17, a view depends on exactly the @Observable properties its body reads, and nothing else.

1

How it works

The explanation, with examples you can run.

Identity: which view is "the same" view

SwiftUI gives every view an identity in one of two ways:

  • Structural: its position and type in the view tree. The two branches of an if are different views, even if both show a Text.
  • Explicit: an id, as in ForEach(items, id: \.id) or .id(value).

State is tied to identity. As long as the identity stays, @State keeps its value however often the struct is recreated. When the identity ends (the branch disappears, or the .id changes), the state is destroyed and any .task attached to the view is cancelled. Give list rows stable ids taken from your data, not array indices or fresh UUID()s, or every reload throws the row state away.

Where truth lives

@Observable final class Library {          // iOS 17+: the Observation framework    var books: [Book] = []    var filter = ""} struct LibraryScreen: View {    @State private var library = Library()  // this view owns it    var body: some View {        BookList(library: library)          // plain property: read-only use        FilterField(library: library)    }} struct FilterField: View {    @Bindable var library: Library           // to make bindings to its properties    var body: some View { TextField("Filter", text: $library.filter) }} struct PlusOneButton: View {    @Binding var count: Int                  // a value owned by someone else    var body: some View { Button("+1") { count += 1 } }}
  • @State: storage this view owns, for values or (since iOS 17) an @Observable object. Keep it private. Note that the default value is created each time the view struct is initialised, even though SwiftUI keeps only the first one; avoid expensive work there and defer it to .task.
  • @Binding: read and write access to a value owned by an ancestor.
  • @Bindable: bindings to the properties of an @Observable object you were handed.
  • @Environment: values and objects passed down the tree without threading them through every initialiser, for example .environment(library) and @Environment(Library.self).

Invalidation: what triggers body

Before iOS 17, models adopted ObservableObject with @Published properties, and a view holding one (@StateObject, @ObservedObject) was invalidated when any published property changed. The @Observable macro, introduced with iOS 17, changed the rule: SwiftUI records which properties body actually reads and invalidates the view only when one of those changes. A list row that reads book.title doesn't redraw when book.isAvailable changes. Observation also tracks optionals and collections of objects, which ObservableObject couldn't.

Two practical consequences:

  • Small views are cheaper. Splitting a big body into subviews narrows what each one depends on, so fewer of them run on each change.
  • body must be fast and free of side effects. It can run often and at times you don't choose; start work in .task or in response to events instead.

Both systems work side by side, so existing ObservableObject code can migrate one type at a time.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.With an @Observable model, a view's body shows only book.title. Another view changes book.isAvailable. Does the first view's body run again?
  2. 2.A screen shows `if loggedIn { ProfileView() } else { LoginView() }`. What happens to ProfileView's @State when loggedIn flips to false and back?
  3. 3.A child view must edit a property of an @Observable model it receives. What do you write in the child?

0 of 3 right so far. Get all three to finish this topic.