---
title: 'Observable Objects'
source: 'https://academia.sh/en/courses/swiftui/observable-objects'
course: 'UI Development with SwiftUI'
language: en
updated: '2026-10-21T05:03:14+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures that views reading a property of an observable object are recomputed with the observed trigger'
    - 'Shows with real observation tracking that only a change to a read property produces a notification'
    - "Separates the screen's own state, which stays local, from the object's properties"
    - 'Builds in the model the difference between object-level and property-level notification'
---

# Observable Objects

Measuring that reading a property of an observable object produces a recomputation on change, verifying with real observation tracking that only a read property notifies, and showing what object-level notification changes.

In the previous lesson a child view read a value through a binding, and the value still lived in a view. In this lesson the value lives not inside a view but in a separate object. A screen's data, the result of a network call or a disk read, a list shared by several screens: instead of being a field of a view, these sit inside an **observable object**. Views hold the object, read its properties, and are recomputed when a property they read changes. The trigger examined in this lesson is **observed**.

The measurement uses the core file, and for the first time in this topic it also measures the compiler's own observation tracking, outside the core.

```swift
// Invalidation.swift — classifies which view a change recomputes and through which source
enum Storage {
    case state(owner: String)
    case model(object: String)
    case environment
}

struct ViewNode {
    let name: String
    let parent: String?
    let reads: Set<String>

    init(_ name: String, parent: String?, reads: Set<String>) {
        self.name = name
        self.parent = parent
        self.reads = reads
    }
}

struct Recompute {
    let view: String
    let cause: String
}

struct ViewTree {
    let nodes: [ViewNode]
    let storage: [String: Storage]

    func ancestors(of name: String) -> [String] {
        var found: [String] = []
        var current = nodes.first { $0.name == name }?.parent
        while let next = current {
            found.append(next)
            current = nodes.first { $0.name == next }?.parent
        }
        return found
    }

    func cause(of node: ViewNode, key: String) -> String {
        guard let kind = storage[key] else { return "out-of-scope" }
        switch kind {
        case .environment:
            return "environment"
        case .model:
            return "observed"
        case .state(let owner):
            if owner == node.name { return "local" }
            if ancestors(of: node.name).contains(owner) { return "parent" }
            return "off-rule"
        }
    }

    func change(_ label: String, key: String) -> [Recompute] {
        let hits = nodes.filter { $0.reads.contains(key) }
            .map { Recompute(view: $0.name, cause: cause(of: $0, key: key)) }
        let skipped = nodes.filter { !$0.reads.contains(key) }.map(\.name)
        let recomputed = hits.isEmpty ? "none" : hits.map { "\($0.view) \($0.cause)" }.joined(separator: ", ")
        let rest = skipped.isEmpty ? "none" : skipped.joined(separator: ",")
        print("\(label): \(key) changed | recomputed \(recomputed) | skipped \(rest)")
        return hits
    }
}

func countCauses(_ recomputes: [Recompute]) {
    for cause in ["local", "parent", "observed", "environment"] {
        print("\(cause): \(recomputes.filter { $0.cause == cause }.count)")
    }
}
```

`Storage.model(object:)` says that a value is stored not in a view but in an object. The `cause` function does not enter the ownership question in this case: every view that reads an object property gets the observed trigger, wherever it stands in the tree. The model builds the framework's dependency-tracking rule; it is not the framework itself.

## Property-Level Observation

The object below holds a library's list of books and its borrowed count. The `@Observable` macro makes every stored property of the class observable. The screen hands the object to its children; each child reads only the property it cares about.

```swift
import SwiftUI
import Observation

@Observable
final class LibraryStore {
    var books: [String] = []
    var borrowedCount = 0
}

struct LibraryScreen: View {
    let store: LibraryStore
    @State private var searchText = ""

    var body: some View {
        VStack {
            BookList(store: store, query: searchText)
            BorrowBadge(store: store)
        }
    }
}

struct BookList: View {
    let store: LibraryStore
    let query: String

    var body: some View {
        List(store.books.filter { $0.contains(query) }, id: \.self) { title in
            Text(title)
        }
    }
}

struct BorrowBadge: View {
    let store: LibraryStore

    var body: some View {
        Text("Borrowed: \(store.borrowedCount)")
    }
}
```

`BookList` reads only the `books` property, and `BorrowBadge` reads only the `borrowedCount` property. `searchText` is the screen's own state and is not in the object. The tree carries these reads exactly. In the storage table, the two object properties are tied to the `LibraryStore` object, and the search text to the `LibraryScreen` view. Three changes are applied: a book is added, a book is borrowed, a search is typed.

```swift
// main.swift — an object property is observed, the screen's own state produces a local trigger
let storage: [String: Storage] = [
    "books": .model(object: "LibraryStore"),
    "borrowedCount": .model(object: "LibraryStore"),
    "searchText": .state(owner: "LibraryScreen"),
]

let screen = ViewTree(nodes: [
    ViewNode("LibraryScreen", parent: nil, reads: ["searchText"]),
    ViewNode("BookList", parent: "LibraryScreen", reads: ["books", "searchText"]),
    ViewNode("BorrowBadge", parent: "LibraryScreen", reads: ["borrowedCount"]),
], storage: storage)

let recomputes = [
    screen.change("book-added", key: "books"),
    screen.change("book-borrowed", key: "borrowedCount"),
    screen.change("search-typed", key: "searchText"),
].flatMap { $0 }

countCauses(recomputes)
```

```
book-added: books changed | recomputed BookList observed | skipped LibraryScreen,BorrowBadge
book-borrowed: borrowedCount changed | recomputed BorrowBadge observed | skipped LibraryScreen,BookList
search-typed: searchText changed | recomputed LibraryScreen local, BookList parent | skipped BorrowBadge
local: 1
parent: 1
observed: 2
environment: 0
```

`book-added` recomputed only `BookList`, and `book-borrowed` recomputed only `BorrowBadge`; both are observed. Two views hold the same object, but each reads a different property of it, and a change to one does not affect the other. The `search-typed` row is not observed: the value lives on the screen, not in the object, so `LibraryScreen` was recomputed with the local trigger and `BookList` with the parent trigger. The counts: local one, parent one, observed two.

What sets the trigger here is not the object itself but the property that is read. The same `BookList` was recomputed with the observed trigger for `books` and with the parent trigger for `searchText`. This shows that the result of the second lesson holds for objects too: the trigger depends not on the type of the view but on where the value being read lives.

**SN7.** This measurement covers three changes and three keys. The case where an object property is read only indirectly in a view's body, for example through a computed property, was not measured; in the model, every view carries the key it reads directly in `reads`.

## Real Tracking: Only the Read Property

The measurement above is a model; the tree builds the rule as we know it. The rule itself, that in an `@Observable` object only a change to a read property produces a notification, is the behavior of the compiler and the Observation module, and it can be measured directly. `withObservationTracking` tracks the properties read in its first closure and calls the second closure when one of them changes.

```swift
// main.swift — real observation tracking: only the read property produces a notification
import Observation

@Observable
final class LibraryStore {
    var books: [String] = []
    var borrowedCount = 0
}

let library = LibraryStore()
withObservationTracking {
    _ = library.borrowedCount
} onChange: {
    print("tracked property changed")
}
library.books.append("algorithms")
print("books changed")
library.borrowedCount += 1
print("borrowedCount changed 1")
library.borrowedCount += 1
print("borrowedCount changed 2")
```

```
books changed
tracked property changed
borrowedCount changed 1
borrowedCount changed 2
```

The tracking read only the `borrowedCount` property. When `books` changed, the callback did not run: there is no notification before the `books changed` line in the output. When `borrowedCount` changed, the callback ran, and the `tracked property changed` line was printed right after the assignment, before the `borrowedCount changed 1` line. So the notification arrived inside the assignment and synchronously. On the second increment, no notification came: tracking is **one-shot**. Once a change has been reported, tracking ends, and it must be set up again to see the next change. In views, setting it up again is the framework's job; this lesson does not measure it.

These two observations support the two rows in the tree. A change to a property that is not read recomputes nothing, and a change to a property that is read informs only the side that set up that read. In the tree, `BorrowBadge` being skipped when `books` changes is the model's reflection of this behavior.

**SN8.** This measurement concerns the `Observation` module itself; how the framework connects this tracking to a view body, and how many times the body is called, was not measured. Whether the callback runs before or after the assigned value is written was not measured either; the output shows only that it runs inside the assignment.

## The Bounding Measurement

Not every observation mechanism works at the property level. An older path, `ObservableObject` and `@Published`, notifies at the object level: when any published property of the object changes, every view that watches the object is recomputed. This path depends on the Combine framework; it did not build in the measurement environment, so its source can only be shown.

```swift
import SwiftUI
import Combine

final class LibraryStore: ObservableObject {
    @Published var books: [String] = []
    @Published var borrowedCount = 0
}

struct BookList: View {
    @ObservedObject var store: LibraryStore

    var body: some View {
        List(store.books, id: \.self) { title in
            Text(title)
        }
    }
}

struct BorrowBadge: View {
    @ObservedObject var store: LibraryStore

    var body: some View {
        Text("Borrowed: \(store.borrowedCount)")
    }
}
```

The measurement below builds the model of this rule; it does not measure the framework itself. With object-level notification, the storage table has a single key, and the key is the name of the object. Every watching view reads this key, because the view cannot tell which property of the object changed.

```swift
// main.swift — with object-level notification the key is the name of the object
let storage: [String: Storage] = [
    "LibraryStore": .model(object: "LibraryStore"),
]

let screen = ViewTree(nodes: [
    ViewNode("LibraryScreen", parent: nil, reads: []),
    ViewNode("BookList", parent: "LibraryScreen", reads: ["LibraryStore"]),
    ViewNode("BorrowBadge", parent: "LibraryScreen", reads: ["LibraryStore"]),
], storage: storage)

let recomputes = [
    screen.change("book-added", key: "LibraryStore"),
    screen.change("book-borrowed", key: "LibraryStore"),
].flatMap { $0 }

countCauses(recomputes)
```

```
book-added: LibraryStore changed | recomputed BookList observed, BorrowBadge observed | skipped LibraryScreen
book-borrowed: LibraryStore changed | recomputed BookList observed, BorrowBadge observed | skipped LibraryScreen
local: 0
parent: 0
observed: 4
environment: 0
```

Two changes recomputed two views twice each; the observed count is four. In the first, property-level measurement, the same two changes had produced two recomputations. The difference comes from the detail of the rule: because the key is the name of the object with object-level notification, the `book-added` change recomputed `BorrowBadge` as well. These rows are not a measurement of the framework; they are the model of the rule. The lesson builds this model to show what property-level observation gains: the view that does not read the changed property is skipped.

**SN9.** This measurement builds the rule of object-level notification as a model. How a real `ObservableObject` publishes, and whether a view is recomputed when a publication arrives with the same value, was not measured.

## Summary

- A view that reads a property of an observable object is recomputed with the observed trigger when the property changes.
- Observation is at the property level: a change to a read property produces a notification, a change to an unread property does not.
- Real tracking is one-shot; to see a second change, tracking is set up again.
- The screen's own state is separate from the object's properties and produces a local or parent trigger.
- Object-level notification recomputes every view that watches the object on every change; property-level observation separates this.
- This lesson's trigger: **observed**.

## Next Step

In this lesson we gave the object to the children as a parameter; the screen held the object and the child received it. Carrying the object to every view in the tree one by one forces the views in between to know the object only to pass it along. In the next lesson a value is placed once along the tree, and every view beneath it reads it directly. This time the trigger is environment.
