State and Data Flow with Observation

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

A SwiftUI view is a description of the interface for some data. When that data changes, SwiftUI works out which views depend on it and evaluates their body again. This page is about the second half of that sentence: where the data lives, who is allowed to change it, and how SwiftUI finds out that it changed. Everything here is written against iOS, iPadOS, macOS, watchOS and visionOS 27, built with Xcode 27 and Swift 6.4, while FieldNotes keeps its deployment minimum of 26. The two tools that differ between those versions, the @State macro and the fine-grained tracking APIs of SE-0506, are labelled where they appear.

The Observation language feature (what the @Observable macro expands to, how property access is recorded) is explained in the Swift reference under Properties (The Observation Module’s @Observable), and macros in general under Macros. This page does not repeat them. It covers what SwiftUI, UIKit and AppKit do with an observable model. The architectural choice of one app-wide store, and where view models fit, is on App Architecture the Apple Way; this page is the detailed reference for the tools that choice relies on.

One Source of Truth

Every piece of data in the interface has exactly one owner, the source of truth. Every other view that shows or edits the value gets access to that owner: a read-only copy, a binding, or a reference to an observable object. Two views never keep their own copies of the same value and try to keep them in sync; that is how interfaces end up showing a note as pinned in the list and unpinned in the detail pane.

In FieldNotes the owners are few and easy to name:

Data Owner (source of truth) How other views reach it

All notes and notebooks

NoteStore, created once in FieldNotesApp and held in @State

.environment(store) in the App; @Environment(NoteStore.self) in any view

Which notebook and which note are selected

ContentView, as two @State properties

$selectedNotebook and $selectedNote passed as @Binding to NotebookList and NoteList

The search text of the note list

NoteList, as @State

$searchText passed to .searchable

The note being edited

The Note value inside store.notes

@Bindable var store = store in NoteEditor, then $store.notes[index].title

The user’s preferred sort order

UserDefaults, through @AppStorage

Every view that declares the same @AppStorage key

The selection to restore after relaunch

The scene’s restoration archive, through @SceneStorage

ContentView only

Two questions decide where a new piece of data belongs. Is it the user’s data (it should survive the screen disappearing, it may be synced, it is tested without a UI)? Then it goes into the model. Does it only describe this view on this screen right now (a sheet is open, a field has focus, the search text)? Then it is view state, owned by the highest view that needs it and passed down.

Figure: From Source of Truth to Views

flowchart TD APP["FieldNotesApp
@State store = NoteStore()"] APP -->|".environment(store)"| ENV(["Environment"]) ENV -->|"@Environment(NoteStore.self)"| CV["ContentView
@State selectedNotebook
@State selectedNote"] CV -->|"$selectedNotebook (Binding)"| NBL["NotebookList
@Binding selection"] CV -->|"$selectedNote (Binding)"| NL["NoteList
@Binding selection
@State searchText"] CV -->|"noteID (plain value)"| NE["NoteEditor
@Bindable var store = store"] ENV -->|"@Environment(NoteStore.self)"| NL ENV -->|"@Environment(NoteStore.self)"| NE NL -->|"note (plain value)"| ROW["NoteRow
let note: Note"] NE -->|"$store.notes[i].title"| TF["TextField"] TF -.->|"writes through the binding"| APP NBL -.->|"writes selection"| CV

Solid arrows are reads flowing down; dashed arrows are writes flowing back up to the owner through a binding. Nothing flows sideways: NoteList never talks to NoteEditor directly. When the user types in the editor, the write lands in NoteStore, and Observation redraws the NoteRow in the list because that row read the same note’s title.

@State: Storage Owned by a View

A view struct is recreated every time its parent’s body runs, so an ordinary stored property cannot hold anything that must survive. @State asks SwiftUI to keep the storage outside the struct, tied to the view’s identity in the hierarchy, and to re-evaluate body when the value changes. Declare it private and give it an initial value in the declaration: the state belongs to this view, and nothing outside should set it.

// FieldNotes/Views/NoteList.swift (excerpt)
struct NoteList: View {
    @Environment(NoteStore.self) private var store
    @State private var searchText = ""
    @State private var isComposing = false

    let notebookID: Notebook.ID?
    @Binding var selection: Note.ID?

    var body: some View {
        List(visibleNotes, selection: $selection) { note in
            NoteRow(note: note)
        }
        .searchable(text: $searchText)
        .toolbar {
            Button("New Note", systemImage: "square.and.pencil") { isComposing = true }
        }
        .sheet(isPresented: $isComposing) {
            NoteComposer(notebookID: notebookID)
        }
    }

    private var visibleNotes: [Note] {
        store.notes.filter { note in
            note.notebookID == notebookID
                && (searchText.isEmpty || note.title.localizedStandardContains(searchText))
        }
    }
}

$searchText is the projected value of the state: a Binding<String> that reads and writes the same storage. visibleNotes is a computed property rather than a second piece of state, which is the most important habit on this page: derive, don’t duplicate. A filtered array stored in @State would have to be kept in sync with both store.notes and searchText; a computed property is always right. Apple’s documentation also notes that you can mutate a state property safely from any thread.

@State Is a Macro in Xcode 27

Built with Xcode 27, the @State attribute no longer resolves to the State property wrapper but to the State() macro. Source code does not change: you still write @State private var isComposing = false, and you still get $isComposing. What changes is when the initial value expression runs. Apple’s SwiftUI release notes state that "this change only initializes and stores your property once when it’s a class".

With the old property wrapper, SwiftUI created a fresh State wrapper every time the struct was initialized, and the initial value expression ran each time. For a value type that was cheap and invisible. For a class it meant a new instance was constructed and immediately thrown away on every parent update, because SwiftUI kept the original storage. An initializer with side effects (opening a file, starting a timer, registering an observer) ran again and again:

// FieldNotes/Views/NoteComposer.swift (excerpt)
@Observable
final class NoteDraft {
    var title = ""
    var body = ""
    var tags: [String] = []

    init() {
        // Before Xcode 27 this ran on every re-initialization of NoteComposer,
        // and every instance but the first was discarded.
        // Built with Xcode 27, it runs once, when SwiftUI first creates the view.
    }
}

struct NoteComposer: View {
    @State private var draft = NoteDraft()
    let notebookID: Notebook.ID?
    // ...
}

The macro is a compile-time change. A FieldNotes build made with Xcode 27 gets the once-only behaviour on iOS 26 devices as well, because what matters is the toolchain that expanded the macro, not the OS the app runs on. Builds made with Xcode 26 or earlier keep using the property wrapper. Code that must also build with the older toolchain should therefore still keep model initializers free of side effects; start work in .task instead.

Initializer Pitfalls

@State is initialized from its declaration once, when the view first appears at that position in the hierarchy. Later values passed through the view’s initializer are ignored. This catches everyone who tries to seed state from a parameter:

// Wrong: the draft title is captured the first time only.
struct TitleField: View {
    @State private var draftTitle: String

    init(note: Note) {
        _draftTitle = State(initialValue: note.title)
    }

    var body: some View {
        TextField("Title", text: $draftTitle)
    }
}

If the user selects another note and SwiftUI reuses the same TitleField (same position, same type), the field keeps showing the first note’s title. The macro does not change this; it is how view identity works. There are three correct designs, in order of preference:

  1. Don’t copy. If the value already has an owner, take a Binding to it (next section) or bind straight into the store with @Bindable.

  2. Give the view a new identity. .id(note.id) tells SwiftUI that a different note means a different view, so the state is destroyed and re-created with the new initial value. View identity is covered on SwiftUI Views and Modifiers.

  3. Reset explicitly. Keep the state and reset it with onChange(of: note.id), covered below.

// Right: a new note is a new view, so the state starts over.
NoteDetail(note: note)
    .id(note.id)

Two smaller rules follow from the same storage model. Reading a @State property inside the view’s own init reads the initial value, not the current one, because the storage is not connected to the struct until SwiftUI installs it. And state declared in a view that is not in the hierarchy yet (for example a view built and stored in a variable, never shown) is never installed at all.

@Binding: Read-Write Access Without Ownership

A @Binding property is a reference to someone else’s source of truth. The child reads it like a normal property and writes to it like one, and the write goes to the owner. NotebookList doesn’t own the selected notebook; ContentView does:

// FieldNotes/Views/ContentView.swift (excerpt)
struct ContentView: View {
    @State private var selectedNotebook: Notebook.ID?
    @State private var selectedNote: Note.ID?

    var body: some View {
        NavigationSplitView {
            NotebookList(selection: $selectedNotebook)
        } content: {
            NoteList(notebookID: selectedNotebook, selection: $selectedNote)
        } detail: {
            if let selectedNote {
                NoteEditor(noteID: selectedNote)
            }
        }
    }
}

Bindings compose. $note.title on a Binding<Note> is a Binding<String> to one property, through dynamic member lookup. TagEditor edits the tags of a note it doesn’t own, and each tag field binds to one element of the array:

// Packages/FieldNotesUI/Sources/FieldNotesUI/TagEditor.swift
import SwiftUI

public struct TagEditor: View {
    @Binding var tags: [String]
    @State private var newTag = ""

    public init(tags: Binding<[String]>) {
        _tags = tags
    }

    public var body: some View {
        // Identify rows by position, not by the text being edited: with `id: \.self` every keystroke
        // would change the row's identity and the field would lose focus.
        ForEach(tags.indices, id: \.self) { index in
            TextField("Tag", text: $tags[index])
        }
        .onDelete { tags.remove(atOffsets: $0) }

        TextField("Add tag", text: $newTag)
            .onSubmit {
                let trimmed = newTag.trimmingCharacters(in: .whitespaces)
                if !trimmed.isEmpty, !tags.contains(trimmed) { tags.append(trimmed) }
                newTag = ""
            }
    }
}

$tags[index] derives a binding to one element of the bound array, so each field edits the tag in place. The rows are identified by index rather than by the tag string: a string that changes as the user types is not a stable identity, and SwiftUI would recreate the field (dropping focus) after every character. When no owner has a property of exactly the right shape, build a binding by hand with Binding(get:set:). It is the right tool for adapting a value, for example exposing "is this note in the pinned section" as a Bool that routes the write through the store’s togglePin(_:):

// FieldNotes/Views/NoteDetail.swift (excerpt)
Toggle("Pinned", isOn: Binding(
    get: { note.isPinned },
    set: { newValue in if newValue != note.isPinned { store.togglePin(note) } }
))

Previews and tests often need a binding with no owner behind it. .constant(value) produces one that ignores writes, and the @Previewable macro lets a preview declare real @State inline, which is the better choice when the control should actually respond:

#Preview {
    @Previewable @State var tags = ["birds", "wetlands"]
    Form { TagEditor(tags: $tags) }
}

Observable Models

Value types in @State cover view state. Shared, long-lived data belongs in a reference type marked with the @Observable macro, from the Observation framework. SwiftUI records which properties of which objects a body read while it ran, and re-evaluates that body only when one of those properties changes. This is per-property, per-view tracking, not per-object.

// Packages/FieldNotesKit/Sources/FieldNotesServices/NoteStore.swift (excerpt)
import Observation
import FieldNotesModel

@Observable
@MainActor
public final class NoteStore {
    public var notes: [Note]
    public var notebooks: [Notebook]
    public var lastSyncDate: Date?

    @ObservationIgnored private let syncer: any NoteSyncing
    @ObservationIgnored private var searchIndex: [String: Set<Note.ID>] = [:]

    public var pinnedNotes: [Note] { notes.filter(\.isPinned) }

    // init, add(_:), delete(_:), togglePin(_:) as on the architecture page
}

The consequences are what make Observation pleasant to use:

  • Only what a view reads counts. NoteRow reads note.title; the sidebar reads store.notebooks. Setting lastSyncDate redraws only the status view that shows it. With ObservableObject, every @Published change invalidated every view that held the object.

  • Computed properties just work. pinnedNotes reads notes, so a view that reads pinnedNotes is tracked on notes. No extra annotation is needed.

  • Reads through optionals, arrays and nested observables are tracked. A view that reads store.notes[3].title depends on notes (the array is one value). A view that reads a property of an observable object held inside another observable object depends on both.

  • Tracking happens during body only. Reading a property in a button’s action closure or in .task doesn’t create a dependency, which is correct: those closures don’t render anything.

NoteStore is marked @MainActor because views read it and UI events mutate it. Apps created from the Xcode 26 and 27 templates default the app target to main-actor isolation, but NoteStore lives in a package that doesn’t get that setting, so the attribute is written out, as explained on Swift Packages and Modularizing an App. The concurrency rules themselves are in Actors, Isolation and Sendable.

@ObservationIgnored

Every stored property of an @Observable class is tracked unless it is marked @ObservationIgnored. Ignore properties that no view should redraw for: injected services, caches, tasks, loggers, and derived indexes that are rebuilt from a tracked property anyway. In NoteStore, syncer never changes and searchIndex is an internal cache; tracking them would only add bookkeeping.

An ignored property is still stored and still mutable; views that read it simply don’t update when it changes. If a view does need to show it, it must not be ignored.

Owning, Passing and Binding an Observable Object

There are three roles a view can play with an observable object, and each has one spelling:

  • Owner — the view that creates the object holds it in @State, so it is created once and survives re-renders. FieldNotesApp owns NoteStore; NoteComposer owns its NoteDraft.

  • Reader — a view that only reads takes a plain let or var property (or reads it from the environment). No wrapper is needed: tracking is done by the object, not by the property.

  • Editor — a view that needs Bindings to the object’s properties declares it @Bindable.

NoteDraftForm is an editor of a draft it doesn’t own:

// FieldNotes/Views/NoteDraftForm.swift
import SwiftUI
import FieldNotesUI

struct NoteDraftForm: View {
    @Bindable var draft: NoteDraft

    var body: some View {
        Form {
            TextField("Title", text: $draft.title)
            TextField("Body", text: $draft.body, axis: .vertical)
            Section("Tags") {
                TagEditor(tags: $draft.tags)
            }
        }
    }
}

NoteEditor edits a note that lives in the store, which it reads from the environment. The environment gives it a plain reference, so it creates a @Bindable local variable inside body. From there, $store.notes[index] is a binding into the array, and every keystroke is a write to the source of truth:

// FieldNotes/Views/NoteEditor.swift
import SwiftUI
import FieldNotesModel
import FieldNotesServices
import FieldNotesUI

struct NoteEditor: View {
    @Environment(NoteStore.self) private var store
    let noteID: Note.ID

    var body: some View {
        @Bindable var store = store
        if let index = store.notes.firstIndex(where: { $0.id == noteID }) {
            Form {
                TextField("Title", text: $store.notes[index].title)
                TextEditor(text: $store.notes[index].body)
                Toggle("Pinned", isOn: $store.notes[index].isPinned)
                Section("Tags") {
                    TagEditor(tags: $store.notes[index].tags)
                }
            }
        } else {
            ContentUnavailableView("No Note Selected", systemImage: "note.text")
        }
    }
}

A common mistake is declaring a passed-in object as @State var draft: NoteDraft in the child. That makes the child an owner of whatever reference it saw first; if the parent later replaces the draft, the child keeps editing the old one. Owners use @State; everyone else uses a plain property or @Bindable.

Note stays a value type when FieldNotes adopts SwiftData: the @Model classes are separate records (NoteRecord, NotebookRecord) behind a persistence layer, and NoteStore still publishes Note values. SwiftData models are observable in exactly the way described here, so the roles above carry over unchanged for the few screens that query records directly.

@Environment: Ambient Values and Shared Objects

The environment is a set of values that flows down the view hierarchy, and every view can read from it without its parent passing anything explicitly. SwiftUI puts system values there (color scheme, locale, dynamic type size, the dismiss and openURL actions, editMode on iOS), and apps add their own.

An observable object is placed in the environment by type, and read by type:

// FieldNotes/FieldNotesApp.swift (excerpt)
WindowGroup {
    ContentView()
        .environment(store)
}

// Any descendant view:
struct NotebookList: View {
    @Environment(NoteStore.self) private var store
    @Environment(\.dismiss) private var dismiss
    @Environment(\.horizontalSizeClass) private var sizeClass
    @Binding var selection: Notebook.ID?
    // ...
}

Reading a non-optional @Environment(NoteStore.self) when no ancestor provided one traps at runtime, so previews must call .environment(…​) too. When an object is genuinely optional, declare it as an optional and SwiftUI returns nil instead:

@Environment(NoteStore.self) private var store: NoteStore?

Values that are not observable objects (a service protocol, a formatting style, a feature flag) are declared as custom environment values with the @Entry macro and set with .environment(\.keyPath, value). FieldNotes' summaryGenerator value is declared that way on App Architecture the Apple Way, and the macro and its Xcode 27 diagnostics are on SwiftUI Views and Modifiers.

Environment reads are tracked like everything else: a view that reads \.horizontalSizeClass re-renders when the size class changes, and one that doesn’t read it isn’t affected.

@AppStorage and @SceneStorage

Two wrappers connect view state to storage that outlives the process.

@AppStorage reads and writes one key in UserDefaults and re-renders the view when that key changes, whether the change came from this view, another view, or another process sharing the same defaults. It suits small user preferences. FieldNotes remembers how the note list is sorted:

// FieldNotes/Views/NoteListSortMenu.swift
import SwiftUI
import FieldNotesModel   // NoteSortOrder: newestFirst, oldestFirst, title (String-backed)

struct NoteListSortMenu: View {
    @AppStorage("noteSortOrder") private var sortOrder: NoteSortOrder = .newestFirst

    var body: some View {
        Picker("Sort By", selection: $sortOrder) {
            Text("Newest First").tag(NoteSortOrder.newestFirst)
            Text("Oldest First").tag(NoteSortOrder.oldestFirst)
            Text("Title").tag(NoteSortOrder.title)
        }
    }
}

@AppStorage supports Bool, Int, Double, String, URL, Date, Data and RawRepresentable types whose raw value is an Int or a String, which is why NoteSortOrder is a String-backed enum. To share a preference with the widget extension, pass the App Group’s defaults as the store:

@AppStorage("showsPinnedOnly", store: UserDefaults(suiteName: "group.com.example.fieldnotes"))
private var showsPinnedOnly = false

@SceneStorage stores small values per scene, in the state the system saves for restoration, so each iPad or Mac window reopens on its own notebook and note. The system discards it when the user explicitly closes the scene, so it is only for UI state:

// FieldNotes/Views/ContentView.swift (excerpt)
@SceneStorage("ContentView.selectedNoteID") private var storedNoteID: String?

Neither wrapper is a database. Keep values small, never store secrets there (use the Keychain, on Security and Privacy), and keep notes in the store. UserDefaults itself, App Group containers, and when a preference outgrows a single key are covered on Preferences, Files and Documents; restoring the selection at launch is covered on App Structure and Life Cycle.

Reacting to Changes with onChange

Most of the time a change needs no code at all: the view that reads the value re-renders. onChange(of:initial:_:) is for side effects that must happen when a value changes: resetting a draft, scrolling, logging, or saving. The value must be Equatable; the action gets the old and the new value, or neither:

// FieldNotes/Views/ContentView.swift (excerpt)
.onChange(of: selectedNote) { _, newValue in
    storedNoteID = newValue?.uuidString
}
.onChange(of: store.notes.count, initial: true) {
    if store.notes.isEmpty { selectedNote = nil }
}

initial: true also runs the action when the view first appears, so the "empty store" rule holds from launch. Three habits keep onChange out of trouble:

  • Don’t derive state with it. If an action only computes a value from another value and stores it, replace both with a computed property.

  • Don’t write back to what you observe. An action that changes the value it watches loops until SwiftUI gives up with a runtime warning.

  • Prefer .task(id:) for async work. .task(id: selectedNote) { …​ } cancels the previous run when the value changes, which onChange plus a manually stored Task would have to reimplement.

Observing Outside SwiftUI: Observations

SwiftUI, UIKit and AppKit track observable models for you. Code that is not a view (a service, a sync engine, a test) needs its own way to watch a model. The Observations type, available from the 26 releases and therefore on every FieldNotes deployment target, turns a closure that reads observable properties into an AsyncSequence of values. It was introduced by Swift Evolution proposal SE-0475 in Swift 6.2.

FieldNotes uses it to schedule a sync a few seconds after the user stops editing:

// Packages/FieldNotesKit/Sources/FieldNotesServices/AutoSyncer.swift
import Observation
import FieldNotesModel

@MainActor
public final class AutoSyncer {
    private let store: NoteStore

    public init(store: NoteStore) {
        self.store = store
    }

    /// Runs until the calling task is cancelled.
    public func run() async {
        let changes = Observations { self.store.notes }
        for await _ in changes {
            try? await Task.sleep(for: .seconds(3))
            guard !Task.isCancelled else { return }
            try? await store.refresh()
        }
    }
}

// FieldNotes/FieldNotesApp.swift (excerpt)
WindowGroup {
    ContentView()
        .environment(store)
        .task { await AutoSyncer(store: store).run() }
}

The semantics are transactional. The sequence starts watching at the first willSet of a tracked property and emits the new value at the next suspension point of the isolation the closure runs on, so ten changes made in one synchronous burst (say, a sync that inserts ten notes) produce one element, not ten. The first element is the current value, emitted immediately. The element type must be Sendable; Note is declared Sendable in FieldNotesModel for exactly this reason. Observations.untilFinished(_:) builds a sequence whose closure returns .next(value) to keep going or .finish to end it, for watches that have a natural end:

// Wait until the first sync has produced at least one note, then stop watching.
let firstNotes = Observations.untilFinished {
    store.notes.isEmpty ? .next(0) : .finish
}
for await _ in firstNotes { }

Iterating the sequence is ordinary Swift concurrency: the loop ends when the task is cancelled, and .task cancels it when the view disappears. How tasks and cancellation work is in Async/Await and Tasks, and how they fit an app is on Swift Concurrency in Apps.

Fine-Grained Change Notifications (SE-0506, New in 27)

Observations coalesces changes and only delivers them asynchronously. That is right for almost every app-level use, and wrong for a few infrastructure ones: keeping two models synchronized immediately, reacting to the value after it is stored, noticing that an observed object was deallocated, or driving a synchronous UI system. Swift Evolution proposal SE-0506, Advanced Observation Tracking, implemented in Swift 6.4, adds two APIs for those cases. They ship in the Observation framework of the 27 releases, so FieldNotes guards them:

  • withObservationTracking(options:_:onChange:) — a one-shot observation, like the original withObservationTracking, but with ObservationTracking.Options choosing .willSet, .didSet and .deinit events, and an ObservationTracking.Event that reports its kind, can matches(_:) a key path to tell which property changed, and can cancel() further events.

  • withContinuousObservation(options:apply:) — runs the closure once immediately (an .initial event), tracks what it reads, re-arms itself after every event and calls the closure again on the caller’s isolation, returning an ObservationTracking.Token that keeps the observation alive until it is released or cancel()-ed.

// FieldNotes/Diagnostics/PinAuditor.swift
import Observation
import FieldNotesServices

@MainActor
final class PinAuditor {
    private var token: ObservationTracking.Token?

    init(store: NoteStore) {
        if #available(iOS 27, macOS 27, watchOS 27, visionOS 27, *) {
            token = withContinuousObservation(options: [.didSet]) { [weak store] event in
                guard let store else { return }
                // Reading pinnedNotes (and so notes) registers what to track next.
                let pinnedCount = store.pinnedNotes.count
                if event.kind == .initial || event.matches(\NoteStore.notes) {
                    print("Pinned notes now:", pinnedCount)
                }
            }
        }
    }
}

The proposal says explicitly that these are specialized tools for middleware and UI-system authors; ordinary app code is better served by views and Observations. The Swift Evolution text names the continuous function withContinuousObservationTracking; the shipping Observation framework documents it as withContinuousObservation(options:apply:), and the example uses the shipping name. The exact shape of the closure (which properties the continuous closure tracks on its first run) should be checked against the framework documentation when the example is compiled.

Migrating from ObservableObject, @StateObject and @Published

Code written before iOS 17 uses the Combine-based ObservableObject protocol. It still compiles, and the two systems coexist in one app, so migration can go one type at a time. Each piece has a one-to-one replacement:

Before (ObservableObject) After (Observation) Notes

class NoteStore: ObservableObject

@Observable class NoteStore

Remove the protocol conformance and add the macro.

@Published var notes: [Note]

var notes: [Note]

Every stored property is tracked; add @ObservationIgnored to those that shouldn’t be.

@StateObject private var store = NoteStore()

@State private var store = NoteStore()

Built with Xcode 27, the initializer runs once, like @StateObject always did.

@ObservedObject var store: NoteStore

var store: NoteStore or @Bindable var store: NoteStore

Use @Bindable only where the view needs $store.property bindings.

.environmentObject(store)

.environment(store)

@EnvironmentObject var store: NoteStore

@Environment(NoteStore.self) private var store

Optional form: @Environment(NoteStore.self) private var store: NoteStore?.

store.$notes.sink { …​ } (Combine publisher)

for await notes in Observations { store.notes } { …​ }

No $property publishers exist on an @Observable type.

Here is NoteStore's pre-Observation ancestor and its migrated form side by side, with a view that used it:

// Before
final class LegacyNoteStore: ObservableObject {
    @Published var notes: [Note] = []
    @Published var isSyncing = false
    let syncer: any NoteSyncing = NoOpNoteSyncer()
}

struct LegacyNoteCount: View {
    @EnvironmentObject var store: LegacyNoteStore
    var body: some View { Text("\(store.notes.count) notes") }
    // Re-renders when isSyncing changes too, because any @Published change
    // fires objectWillChange for the whole object.
}

// After
@Observable
final class MigratedNoteStore {
    var notes: [Note] = []
    var isSyncing = false
    @ObservationIgnored let syncer: any NoteSyncing = NoOpNoteSyncer()
}

struct NoteCount: View {
    @Environment(MigratedNoteStore.self) private var store
    var body: some View { Text("\(store.notes.count) notes") }
    // Re-renders only when notes changes.
}

Things to check during a migration:

  • Combine subscribers. Anything that subscribed to objectWillChange or a $property publisher needs a new mechanism: Observations for async code, onChange in views, or UIKit/AppKit automatic tracking (next section).

  • didSet side effects. Property observers still work on @Observable properties, but code that relied on @Published emitting before the change (willSet semantics) should be re-read.

  • Performance expectations flip. A large ObservableObject used to be a reason to split views into sub-objects to limit re-renders. With Observation, re-renders follow property reads, so that splitting is no longer needed for performance.

  • Minimum OS. Observation requires the 17 releases; with a minimum of 26, FieldNotes has no reason to keep ObservableObject anywhere.

Observation Tracking in UIKit and AppKit

UIKit and AppKit track @Observable models too, in specific update methods. Any observable property read inside one of those methods is recorded, and when it changes the framework invalidates the view and calls the method again. There is no setNeedsLayout() to remember and no Combine subscription to store.

In UIKit, the dedicated method is updateProperties() on UIView and UIViewController, new in iOS 26. It runs during the update pass after the trait collection is updated and before layoutSubviews(), and is the place for content and styling (text, colors, visibility). layoutSubviews() and viewWillLayoutSubviews() are tracked too, and are the place for geometry. Cell configurationUpdateHandler closures are tracked, so list cells update when their model changes. FieldNotes hosts one UIKit view controller on iOS, a compact note-count banner embedded in SwiftUI through UIViewControllerRepresentable:

// FieldNotes/UIKit/NoteCountViewController.swift
import UIKit
import FieldNotesServices

final class NoteCountViewController: UIViewController {
    private let store: NoteStore
    private let countLabel = UILabel()

    init(store: NoteStore) {
        self.store = store
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    override func viewDidLoad() {
        super.viewDidLoad()
        countLabel.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(countLabel)
        NSLayoutConstraint.activate([
            countLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            countLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])
    }

    override func updateProperties() {
        super.updateProperties()
        // Reading store.notes here registers a dependency. When it changes,
        // UIKit calls updateProperties() again on the next update pass.
        countLabel.text = "\(store.notes.count) notes"
        countLabel.textColor = store.pinnedNotes.isEmpty ? .secondaryLabel : .label
    }
}

Call setNeedsUpdateProperties() to request another pass for a reason Observation can’t see.

In AppKit the tracked methods are viewWillLayout() on NSViewController, and updateConstraints(), layout() and draw(_:) on NSView, including drawing methods that draw(_:) calls, such as an NSSliderCell subclass’s drawKnob(_:). On macOS, FieldNotes shows its sync status in a small AppKit view controller that follows the same pattern:

// FieldNotes/macOS/SyncStatusViewController.swift
import AppKit
import FieldNotesServices

final class SyncStatusViewController: NSViewController {
    var store: NoteStore!
    private let statusField = NSTextField(labelWithString: "")

    override func loadView() {
        view = statusField
    }

    override func viewWillLayout() {
        super.viewWillLayout()
        if let date = store.lastSyncDate {
            statusField.stringValue = "Synced \(date.formatted(.relative(presentation: .named)))"
        } else {
            statusField.stringValue = "Not synced yet"
        }
    }
}

Automatic tracking is on by default from iOS 26 and macOS 26, FieldNotes' minimum. Apps that still support iOS 18 or macOS 15 opt in on those releases with the UIObservationTrackingEnabled and NSObservationTrackingEnabled Info.plist keys set to YES; on iOS 18, updateProperties() doesn’t exist, so the tracked method there is viewWillLayoutSubviews(). Mixing SwiftUI with UIKit and AppKit in general is on UIKit and AppKit Essentials, and Mixing Them with SwiftUI.

Choosing a Property Wrapper

Every tool on this page answers the same two questions: who owns the data, and does this view need to write it? The table puts them in one place. Start at the top row and use the first one that matches.

Figure: Which Property Wrapper?

If the data is…​ Use FieldNotes example

A value type this view creates and owns (flags, text, selection)

@State private var

@State private var searchText = "" in NoteList

An @Observable object this view creates and owns

@State private var

@State private var store = NoteStore() in FieldNotesApp

A value owned by an ancestor that this view must change

@Binding var

@Binding var selection: Notebook.ID? in NotebookList

An @Observable object passed in, only read

let / var (no wrapper)

let note: Note in NoteRow (a value), var store: NoteStore in a helper view

An @Observable object passed in, needing $ bindings

@Bindable var

@Bindable var draft: NoteDraft in NoteDraftForm

An @Observable object from the environment, needing $ bindings

@Bindable var x = x inside body

@Bindable var store = store in NoteEditor

A shared object many distant views read

.environment(object) + @Environment(Type.self)

NoteStore everywhere

A system or custom ambient value

@Environment(\.keyPath)

@Environment(\.dismiss), @Environment(\.summaryGenerator)

A small user preference that survives relaunch

@AppStorage("key")

@AppStorage("noteSortOrder")

UI state to restore per window after relaunch

@SceneStorage("key")

@SceneStorage("ContentView.selectedNoteID")

A stored property of an @Observable class no view should track

@ObservationIgnored

syncer in NoteStore

Legacy ObservableObject code

@StateObject / @ObservedObject / @EnvironmentObject

Migrate to the rows above

What Comes Next

The note list this page keeps referring to is built in full, with sections, swipe actions, reordering and search, on Lists, Tables and Collections. Selection bindings become navigation state on Navigation and Presentation, and the store becomes persistent on SwiftData. Testing an @Observable store without any UI is on Unit Testing with Swift Testing.

References