WWDC sample code and code-alongs aren't meant to be architectural endorsements, just quick and easy ways to introduce new stuff in the shortest, most isolated way possible so you can see how the syntax works in a brief video.
Your proposed workaround has a couple of key flaws and risks:
It will silently fail during CloudKit Sync and database loads.
When SwiftData pulls an item from the SQLite database into memory or when CK downloads a remote sync change, it will bypass computed properties entirely, and instead directly populate the underlying stored backing field (_title) using the internal BackingData engine.
So if a user modifies an item's title on their iPad, CK will sync that change down to their iPhone. The iPhone will directly apply the update to _title and as a result, because the custom setter for title is completely bypassed updatedAt will never be updated. So now you're dealing with permanent data drift and desync across user devices.
It also breaks the context transaction and undo engine. The transaction engine can't track the dirty state and is unable to handle undo/redo operations because it'll roll back _title but the custom setter won't execute and now updatedAt is stuck in the future.
I think it's also risky for predicate evaluation and KeyPath tracking. When a SwiftUI view evaluates item.title it goes through a computed gateway to read _title, and while basic observation might still find the dependency, once we get to deeply nested filtering, sectioned fetches or complex data graph evaluations things might fail because the public title property doesn't directly map to a verified storage index.
And like you already noted, it forces the underlying database columns to be saved as _TITLE instead of TITLE, so now your database schema is permanently polluted with implementation details because there is no custom columnName modifier. CK console debugging will become very confusing if you ever share the database with a non-SwiftData client and it will make future lightweight schema migrations a nightmare.
I recommend either putting withContinousObservation in a .task modifier if the side effects is strictly for the UI, or using a coordinator and ModelActor if the side effect is for the data.
.task example:
import SwiftUI
import Observation
import SwiftData
struct ActivityDetailView: View {
let activity: Activity
// 1. A stable place to store the token for the lifetime of this view
@State private var observationToken: ObservationTracking.Token?
var body: some View {
Form {
TextField("Activity Name", text: Bindable(activity).name)
LabeledContent("Last Updated") {
Text(activity.updatedAt.formatted(date: .omitted, time: .standard))
}
}
// 2. The task modifier manages the lifecycle safely
.task {
// Cancel any old token just in case of unexpected task restarts
observationToken?.cancel()
// 3. Initialize the loop once when the view appears
observationToken = withContinuousObservation(options: .didSet) { event in
// 4. Check if the mutation matches the property we care about
if event.matches(\Activity.name) {
// Because .task defaults to the @MainActor, this inline write
// runs safely within the main rendering loop
activity.updatedAt = .now
}
}
} // 5. Automatic cleanup! The second the user leaves this screen,
// the task scope cancels, tearing down the token automatically.
}
}
- There is zero
init overhead, if the parent view reevaluates this view 100 times due to an animation or structural layout change the code in .task is completely skipped, it executes exactly once when the view physically takes up space on the screen - Because the
.task modifier inherits @MainActor isolation by default the code block is guaranteed to run on the main thread, so you avoid cross thread data races - You don't have to worry about accidentally leaking memory or creating zombie looks because SwiftUI handles tearing down the async environment the moment the view is removed from the hierarchy
That said, the boundary is visibility, so if that's a deal breaker or you need to avoid blocking the main thread then I'd recommend a different approach.
Coordinator + ModelActor example:
import Observation
import SwiftData
import Foundation
// 1. The model remains clean and free of custom setters
@Model
class Activity {
var name: String
var updatedAt: Date
var token: ObservationTracking.Token? // Kept here if you want it model-scoped
init(name: String) {
self.name = name
self.updatedAt = .now
}
}
// 2. The Background Actor handles the actual database write transaction
@ModelActor
actor ActivityDataEngine {
func applyTimestampSideEffect(for identifier: PersistentIdentifier) {
guard let activity = modelContext.model(for: identifier) as? Activity else { return }
// This is where our actual business logic side-effect lives
activity.updatedAt = .now
// Save the transaction safely off the main thread
try? modelContext.save()
}
}
// 3. The Coordinator Service provides the "stable home" for the continuous observation
@MainActor
class ActivityCoordinator {
private let dataEngine: ActivityDataEngine
private var observationToken: ObservationTracking.Token?
init(container: ModelContainer) {
self.dataEngine = ActivityDataEngine(container: container)
}
func registerPersistentObservation(on activity: Activity) {
// Discard any existing token to prevent duplicates
observationToken?.cancel()
let activityID = activity.id
// This loop is initialized ONCE. It survives view teardowns entirely.
observationToken = withContinuousObservation(options: .didSet) { [weak self] event in
guard let self else { return }
// The moment ANY property changes on this activity, the observation fires.
// We hand the persistent ID over to our background actor to do the heavy lifting.
Task {
await self.dataEngine.applyTimestampSideEffect(for: activityID)
}
}
}
}
- The view layer simply displays the data, when it appears it calls
coordinator.registerPersistentObservation(on: activity) inside a .task, the coordinator holds the single active observation token - By passing the
PersistentIdentifier down to the ActivityDataEngine actor the main thread is instantly freed up, preventing UI stutters