Better alternative to WWDC's `withContinuousObservation` in View initializers for SwiftData?

Hi everyone,

I was watching the "Code-along: Add persistence with SwiftData" session and noticed a strange architectural choice at the end. They track model side-effects directly inside a SwiftUI View's initializer like this:

init(activity: Activity, isLast: Bool, isEditing: Bool) {
    activity.token = withContinuousObservation(options: .didSet) { event in
        // ... side effects here
    }
}

This feels like a significant architectural smell. SwiftUI views are transient structures with no guaranteed lifetime—they can be initialized dozens of times a second during standard layout passes. Furthermore, if multiple views display or interact with the same Activity, this tracking work gets duplicated redundantly.

I understand this is a workaround because attaching a standard didSet directly to a stored property inside a @Model class doesn't trigger cleanly due to how the macro expands back-end storage.

To keep this data-logic in the model layer where it belongs, I came up with an alternative that maps a custom computed property over a real stored attribute using. Here is the pattern:

import SwiftUI
import SwiftData

@Model
class Item {
    // 1. Persist the actual database column under an internal property name
    private var _title: String
    
    // 2. Expose a public computed property to intercept mutations
    var title: String {
        get { 
            _title 
        }
        set {
            // Updating the backing variable automatically fires the macro's observation hooks
            _title = newValue
            updatedAt = .now // Our derived side-effect!
        }
    }

    var updatedAt: Date
    
    init(title: String) {
        self._title = title
        self.updatedAt = .now
    }
}

Why I prefer this over the WWDC approach:

  1. Separation of Concerns: The model handles its own data dependencies (updatedAt), meaning the View layer remains purely declarative.
  2. Predictable Execution: The mutation logic runs exactly once per write, regardless of how many views are rendering or re-initializing around the object.
  3. No Manual Observation Setup: Because _title is a real, macro-backed attribute, SwiftData’s generated access and withMutation hooks are invoked naturally when the computed property reads or writes to it. We don't have to manually manage tokens or observation blocks.

What do you all think? Are there any hidden gotchas to manipulating the schema mapping via originalName like this, or is this a vastly superior layout to WWDC's view-bound observation snippet?

The downside is now the SQLIte column is _TITLE instead of TITLE. Is there any workaround for that? There doesn't seem to be @Attribute(columnName: "title")

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
Better alternative to WWDC's `withContinuousObservation` in View initializers for SwiftData?
 
 
Q