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 |
|
|
Which notebook and which note are selected |
|
|
The search text of the note list |
|
|
The note being edited |
The |
|
The user’s preferred sort order |
|
Every view that declares the same |
The selection to restore after relaunch |
The scene’s restoration archive, through |
|
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
@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:
-
Don’t copy. If the value already has an owner, take a
Bindingto it (next section) or bind straight into the store with@Bindable. -
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. -
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.
NoteRowreadsnote.title; the sidebar readsstore.notebooks. SettinglastSyncDateredraws only the status view that shows it. WithObservableObject, every@Publishedchange invalidated every view that held the object. -
Computed properties just work.
pinnedNotesreadsnotes, so a view that readspinnedNotesis tracked onnotes. No extra annotation is needed. -
Reads through optionals, arrays and nested observables are tracked. A view that reads
store.notes[3].titledepends onnotes(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
bodyonly. Reading a property in a button’s action closure or in.taskdoesn’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.FieldNotesAppownsNoteStore;NoteComposerowns itsNoteDraft. -
Reader — a view that only reads takes a plain
letorvarproperty (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, whichonChangeplus a manually storedTaskwould 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 originalwithObservationTracking, but withObservationTracking.Optionschoosing.willSet,.didSetand.deinitevents, and anObservationTracking.Eventthat reports itskind, canmatches(_:)a key path to tell which property changed, and cancancel()further events. -
withContinuousObservation(options:apply:)— runs the closure once immediately (an.initialevent), tracks what it reads, re-arms itself after every event and calls the closure again on the caller’s isolation, returning anObservationTracking.Tokenthat keeps the observation alive until it is released orcancel()-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)
}
}
}
}
}
See Apple Developer Documentation — withContinuousObservation(options:apply:) and Apple Developer Documentation -- withObservationTracking(options:_:onChange:).
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 |
|---|---|---|
|
|
Remove the protocol conformance and add the macro. |
|
|
Every stored property is tracked; add |
|
|
Built with Xcode 27, the initializer runs once, like |
|
|
Use |
|
|
|
|
|
Optional form: |
|
|
No |
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
objectWillChangeor a$propertypublisher needs a new mechanism:Observationsfor async code,onChangein views, or UIKit/AppKit automatic tracking (next section). -
didSetside effects. Property observers still work on@Observableproperties, but code that relied on@Publishedemitting before the change (willSetsemantics) should be re-read. -
Performance expectations flip. A large
ObservableObjectused 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
ObservableObjectanywhere.
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
}
}
See Apple Developer Documentation — Updating views automatically with observation tracking in UIKit.
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"
}
}
}
See Apple Developer Documentation — Updating views automatically with observation tracking in AppKit.
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) |
|
|
An |
|
|
A value owned by an ancestor that this view must change |
|
|
An |
|
|
An |
|
|
An |
|
|
A shared object many distant views read |
|
|
A system or custom ambient value |
|
|
A small user preference that survives relaunch |
|
|
UI state to restore per window after relaunch |
|
|
A stored property of an |
|
|
Legacy |
|
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
-
Apple Developer Documentation — Managing user interface state
-
Apple Developer Documentation — Managing model data in your app
-
Apple Developer Documentation — withContinuousObservation(options:apply:)
-
Apple Developer Documentation -- withObservationTracking(options:_:onChange:)
-
Apple Developer Documentation — Updating views automatically with observation tracking in UIKit
-
Apple Developer Documentation — Updating views automatically with observation tracking in AppKit
-
Swift Evolution — SE-0475 Transactional Observation of Values