iOS / Swift / Concurrency and data boundaries / Lesson 9
Observation and main-actor models
Separate change tracking, mutation authority, actor isolation, and resource ownership in a shared UI model.
On this page
Lesson goals
- Observation tracks reads, not every application concern
- Make isolation explicit
- Grant editing through a deliberate binding surface
Observation tracks reads, not every application concern
Observation lets a SwiftUI view depend on observable properties it reads while describing its body. On the declared iOS 17-or-later baseline, @Observable adds tracking support to a model type. It does not automatically choose a single owner, authorize mutations, serialize all asynchronous workflows, or persist changes. Those remain design decisions. Keep models focused on information and commands the interface needs, while repositories own data-access policy. A model shared by two screens should have a documented lifetime rather than being recreated independently by each screen and accidentally diverging.
Make isolation explicit
The first fragment marks a UI model @MainActor and @Observable. The actor annotation states where its mutable properties are isolated, while the observation annotation enables dependency tracking. They solve different problems. The computed visibleLabels property derives from labels and query, avoiding a second independently mutable filtered array. For this small synthetic dataset, direct derivation is reasonable. If filtering becomes expensive, measure before introducing caching and include all dependencies in invalidation. Do not add unchecked Sendable to make this main-actor model freely mutable from arbitrary concurrent tasks.
import Foundation
import Observation
@MainActor @Observable
final class LabelModel {
var query = ""
private(set) var labels = ["Maps", "Rivers"]
var visibleLabels: [String] {
labels.filter { query.isEmpty || $0.localizedCaseInsensitiveContains(query) }
}
func replaceLabels(_ next: [String]) { labels = next }
}
An empty query exposes both labels; query Maps exposes Maps.
Foundation supplies the localized string comparison. Change tracking and actor isolation remain separate annotations.
Grant editing through a deliberate binding surface
The second fragment receives the shared model and uses @Bindable to create a binding to its editable query. A parent should retain the model under the intended stable ownership scope. Passing a new model during every render would discard the shared source of truth. The view reads visibleLabels, establishing dependencies through that calculation. A binding is appropriate for this reversible search draft; a privileged save should remain a command that validates its inputs. Keep the model’s published mutation surface narrow so a view cannot set a pending flag without starting the corresponding operation.
import SwiftUI
@MainActor
struct LabelScreen: View {
@Bindable var model: LabelModel
var body: some View {
VStack {
TextField("Filter labels", text: $model.query)
ForEach(model.visibleLabels, id: \.self) { label in Text(label) }
}
}
}
Editing the filter changes the derived visible labels under the shared model.
The two synthetic labels are unique immutable strings. General domain rows should use stable record identifiers rather than mutable titles.
Do not confuse main-actor isolation with atomic workflows
A main-actor method can suspend at await and allow another main-actor action to change the model before it resumes. Therefore two loads can still complete out of order without a data race. Add command policy and freshness checks when input or session identity matters. Similarly, observation notifications do not establish that a save was accepted by an authoritative service. Publish coherent loading, success, and failure states according to the operation contract. Keep a meaningful prior result during refresh when that is the intended interaction rather than clearing it as a side effect of task creation.
Own subscriptions and tasks separately from tracked data
A tracked property is not itself a cleanup policy. If the model starts a stream, notification subscription, or retained Task, define when it stops and whether the model can outlive its last view. A view-scoped model and an account-scoped model should not be disposed under the same rule merely because both are observable. Keep BuildContext-like UI capabilities out of repositories; in SwiftUI terms, avoid storing transient environment presentation actions inside data infrastructure. Return an application outcome and let the current view owner choose how to present it.
Verify the model and the presentation independently
Test model commands under the declared actor isolation with synthetic dependencies and assert accepted state. Then verify a view actually reads and reflects the properties relevant to its interface. A model test can prove filtering but cannot prove that the view exposes a usable field label or preserves keyboard focus. Add a shared-owner case where two views observe the same model, plus a deliberate ownership reset. Report the selected Swift mode and SDK for executed checks. Here no Observation macro expansion, compiler, simulator, or device test was run.
Common errors and fixes
An observable model is mutated across isolation boundaries
- Why it happens
- Change tracking was mistaken for concurrency protection.
- How to fix it
- Declare actor isolation and cross it through supported asynchronous access.
- Prevent it next time
- Review concurrency diagnostics before suppressing them.
Two screens display independent copies unexpectedly
- Why it happens
- Each screen creates its own model instead of receiving a shared owner.
- How to fix it
- Create the model in the intended common scope and inject it.
- Prevent it next time
- Test cross-view changes with one retained instance.
Filtered data diverges from the query
- Why it happens
- A redundant mutable filtered array was not updated consistently.
- How to fix it
- Derive it or centralize cache invalidation under the model.
- Prevent it next time
- Test query changes and same-length source replacements.
Check your understanding
1. Does @Observable imply @MainActor?
Show worked solution
No. Observation tracks access and change; actor isolation controls access to isolated state. Annotate the UI model explicitly under the project’s concurrency assumptions. A compiler setting that changes default isolation is also part of the documented execution context.
2. Why is a binding appropriate for query but potentially wrong for a confirmed reservation?
Show worked solution
The query is a local editable preference whose change is the intended operation. A reservation requires validation, authorization, pending policy, and authoritative confirmation. Expose it through a command and result rather than allowing arbitrary property writes to imply success.
Key takeaways
- Observation tracks reads, not every application concern
- Verify the model and the presentation independently