Observe SwiftData Changes Without Rebuilding Your Own Diff Engine

Most SwiftData apps eventually need more than “fetch rows and show them.” A list needs sections. A map wants bounds that follow the current trips. A sync engine wants to know when app-authored changes are ready to upload. A widget or extension might write into the same store while the main app is asleep.

The tempting answer is to keep an old array around, fetch a new array, and diff the two yourself. That works right up until deletes, section moves, app extensions, and sync authors get involved.

The SwiftData additions Apple showed at WWDC26 are aimed at that pressure point. Use sectioned @Query(sectionBy:) when SwiftUI needs grouped results. Use ResultsObserver when non-view code needs query-like observation. Use HistoryObserver when you need a cheap signal that persistent history has new transactions, then call ModelContext.fetchHistory() when that signal says there is work.

One beta note before the code: sectioned Query, .codable, ResultsObserver, HistoryObserver, and ObservationTracking.Token are part of the iOS 27 and macOS 27 SDK generation. ModelContext.fetchHistory(_:) is older, but the observer that tells you when to call it is new. The examples below follow the WWDC26 session shape, so let the compiler and current SDK docs be the source of truth for exact initializer labels.

Start with a model that keeps queryable data queryable

Here is a trip model with a few practical fields. Notice that the MapKit identifier is persisted, but the values we need for filtering, sorting, and sectioning are stored as normal SwiftData attributes.

import Foundation
import MapKit
import SwiftData

@Model
final class Trip {
    var title: String
    var destinationName: String
    var startsAt: Date
    var endsAt: Date
    var latitude: Double
    var longitude: Double
    var isArchived: Bool

    @Attribute(.preserveValueOnDeletion)
    var remoteID: String

    @Attribute(.codable)
    var mapItemIdentifier: MKMapItem.Identifier?

    init(
        title: String,
        destinationName: String,
        startsAt: Date,
        endsAt: Date,
        latitude: Double,
        longitude: Double,
        remoteID: String,
        mapItemIdentifier: MKMapItem.Identifier? = nil
    ) {
        self.title = title
        self.destinationName = destinationName
        self.startsAt = startsAt
        self.endsAt = endsAt
        self.latitude = latitude
        self.longitude = longitude
        self.isArchived = false
        self.remoteID = remoteID
        self.mapItemIdentifier = mapItemIdentifier
    }
}

@Attribute(.codable) is the useful escape hatch here. MKMapItem.Identifier comes from MapKit, you do not own its implementation, and SwiftData cannot inspect it like a model type. Because it conforms to Codable, SwiftData can store its encoded representation.

Do not use that escape hatch for everything. A codable attribute is opaque to SwiftData:

  • You cannot predicate into it.
  • You cannot sort by its internal fields.
  • You cannot index or uniquely constrain values inside it.
  • Changes to the encoded type’s shape do not automatically become SwiftData migrations.
  • Your Codable implementation needs to decode older stored data after the type evolves.

If you need to query by a value, store that value separately in a normal attribute. In the model above, destinationName, latitude, and longitude stay visible to SwiftData. The MapKit identifier is there so the app can later ask MapKit for richer metadata or open the same place in Maps. The remoteID is marked with .preserveValueOnDeletion because the history sync example later needs that server identifier even after the local model has been deleted.

That rule of thumb is simple: use .codable for external types you need to preserve, and keep your app’s searchable state in SwiftData-shaped properties.

Section SwiftUI lists at the fetch layer

Before WWDC26, a grouped list often meant fetching a flat array and doing your own Dictionary(grouping:by:) in the view model or body. That is easy to write, but it makes the view responsible for a second representation of the same fetch.

With sectioned @Query, the grouping belongs to the query. The WWDC26 session shows sectionBy using a key path from the model to a String section value.

import SwiftData
import SwiftUI

struct TripListView: View {
    @Query(
        sort: \Trip.startsAt,
        sectionBy: \.destinationName
    )
    private var trips: [Trip]

    var body: some View {
        List {
            ForEach(_trips.sections) { section in
                Section {
                    ForEach(section) { trip in
                        TripRow(trip: trip)
                    }
                } header: {
                    Text(section.id)
                }
            }
        }
    }
}

The wrapped value is still [Trip], so existing code that reads trips keeps seeing the flat result. The new bit lives on the backing query value. If the property is named trips, use _trips.sections. If you named it query, that would be _query.sections.

Each section has an id, which is the value reached by the sectionBy key path. In this example, that is the destination name. The section itself is a collection of trips, so the inner ForEach iterates the section, not a hand-built grouped array.

This is a nice boundary: SwiftUI still declares the list, while SwiftData owns the result shape. When a trip changes destination, the query can move it between sections without your view inventing its own diff story.

Use ResultsObserver outside SwiftUI views

@Query is still the first thing I would reach for inside a SwiftUI view. It fetches, observes, and invalidates the view when the result changes.

But a lot of useful state does not live directly in a view. A map camera controller, badge calculator, export queue, or menu bar status item might need to track SwiftData changes without pretending to be SwiftUI UI. That is where ResultsObserver fits.

Think of it as query-style observation for ordinary app objects.

import Foundation
import Observation
import SwiftData

@MainActor
@Observable
final class TripSummaryState {
    private let resultsObserver: ResultsObserver<Trip, Never>
    private var observationToken: ObservationTracking.Token?

    var upcomingCount = 0
    var nextDestination: String?

    init(modelContext: ModelContext) throws {
        self.resultsObserver = try ResultsObserver<Trip, Never>(
            modelContext: modelContext
        )

        rebuildSummary(from: resultsObserver.results)

        observationToken = withContinuousObservation(options: [.didSet]) { [weak self] _ in
            guard let self else {
                return
            }

            self.rebuildSummary(from: self.resultsObserver.results)
        }
    }

    private func rebuildSummary(from trips: [Trip]) {
        let now = Date()
        let upcoming = trips
            .filter { !$0.isArchived && $0.startsAt >= now }
            .sorted { $0.startsAt < $1.startsAt }

        upcomingCount = upcoming.count
        nextDestination = upcoming.first?.destinationName
    }
}

The important lifetime detail is ObservationTracking.Token. Store it. If the token is released, continuous observation ends. The observation body establishes what should be tracked by reading resultsObserver.results, and with the .didSet option the update work runs after the observed value changes. For initial state, do the first rebuildSummary yourself before starting the continuous observation.

This example keeps the derived state small on purpose. It does not need to know which row moved, which property changed, or how to diff two arrays. It only needs the current result set whenever SwiftData says the result set changed.

ResultsObserver is a good fit when you care about the current answer:

  • How many upcoming trips are there?
  • Which pins should be shown on the map?
  • What is the next item in a menu bar extra?
  • Should a document export button be enabled?

If you need the transaction stream itself, use history.

Keep authors on your writes

History only becomes really useful when your app labels who wrote each transaction. The older SwiftData history article goes deeper on tokens and tombstones, but this part is worth repeating because it makes the new observer much more useful.

import SwiftData

enum TransactionAuthor {
    static let app = "App"
    static let widget = "Widget"
    static let server = "Server"
}

func renameTrip(
    _ trip: Trip,
    to title: String,
    in context: ModelContext
) throws {
    context.author = TransactionAuthor.app
    trip.title = title
    try context.save()
}

func importTripFromServer(
    _ trip: Trip,
    into context: ModelContext
) throws {
    context.author = TransactionAuthor.server
    context.insert(trip)
    try context.save()
}

Author strings let you avoid feedback loops. If a server import writes a transaction with author "Server", your upload pipeline can ignore it. If the user edits a trip in the app with author "App", that same pipeline can upload it.

Use HistoryObserver as the doorbell

ModelContext.fetchHistory() is still the API that gives you transactions. HistoryObserver does not replace that. It gives you an observable eventCounter that increments when new persistent-history transactions are available.

That makes it useful as a cheap doorbell. Do an initial catch-up pass when your monitor starts, then observe the counter. When it changes, fetch and process the history you have not handled yet.

import Observation
import SwiftData

@MainActor
final class TripUploadMonitor {
    private let historyObserver: HistoryObserver
    private let processor: TripHistoryProcessor
    private var observationToken: ObservationTracking.Token?

    init(
        modelContainer: ModelContainer,
        processor: TripHistoryProcessor
    ) throws {
        self.historyObserver = try HistoryObserver(
            authors: [TransactionAuthor.app],
            modelContainer: modelContainer
        )
        self.processor = processor
    }

    func start() {
        Task {
            await processor.processAvailableHistory()
        }

        observationToken = withContinuousObservation(options: [.didSet]) { [weak self] _ in
            guard let self else {
                return
            }

            _ = self.historyObserver.eventCounter
            let processor = self.processor

            Task {
                await processor.processAvailableHistory()
            }
        }
    }
}

Two small details matter in that closure.

First, read historyObserver.eventCounter inside the observation body. That is the observable property you want Observation to track. You do not need the value itself; reading it wires the dependency. The initial processAvailableHistory() call handles transactions that arrived before this object started observing.

Second, store the ObservationTracking.Token, just like with ResultsObserver. The token defines the lifetime of the observation.

The observer is filtered to TransactionAuthor.app, so it is only ringing for app-authored work. Apple also describes model-type filtering for history observation in the WWDC26 session. Because this is beta-era API surface, check the current SDK for the exact initializer shape before baking more specific filters into shared code.

Fetch history only after the counter moves

The processor can now do the expensive part only when it has been asked to. It loads the last saved history token, fetches newer app-authored transactions, uploads the relevant trip changes, and saves the latest token. If the fetch limit is smaller than the backlog, drain batches until there is no more work.

import SwiftData

protocol TripHistoryTokenStore {
    func load() throws -> DefaultHistoryToken?
    func save(_ token: DefaultHistoryToken) throws
}

actor TripHistoryProcessor {
    struct Batch {
        var upsertIDs = Set<PersistentIdentifier>()
        var deletedRemoteIDs: [String] = []
        var latestToken: DefaultHistoryToken?
    }

    private let modelContainer: ModelContainer
    private let tokenStore: TripHistoryTokenStore

    init(
        modelContainer: ModelContainer,
        tokenStore: TripHistoryTokenStore
    ) {
        self.modelContainer = modelContainer
        self.tokenStore = tokenStore
    }

    func processAvailableHistory() async {
        do {
            try processBatch()
        } catch {
            // Record the error and retry later. Do not advance the token
            // until the matching remote work has succeeded.
        }
    }

    private func processBatch() throws {
        let context = ModelContext(modelContainer)
        var lastToken = try tokenStore.load()
        let fetchLimit: UInt64 = 200

        while true {
            let transactions = try fetchTransactions(
                after: lastToken,
                limit: fetchLimit,
                context: context
            )

            guard !transactions.isEmpty else {
                return
            }

            let batch = tripChanges(in: transactions)
            try uploadTripChanges(batch, using: context)

            if let newestToken = batch.latestToken {
                try tokenStore.save(newestToken)
                lastToken = newestToken
            }

            if transactions.count < Int(fetchLimit) {
                return
            }
        }
    }

    private func fetchTransactions(
        after token: DefaultHistoryToken?,
        limit: UInt64,
        context: ModelContext
    ) throws -> [DefaultHistoryTransaction] {
        let author = TransactionAuthor.app
        var descriptor = HistoryDescriptor<DefaultHistoryTransaction>()
        descriptor.fetchLimit = limit

        if let token {
            descriptor.predicate = #Predicate { transaction in
                transaction.token > token && transaction.author == author
            }
        } else {
            descriptor.predicate = #Predicate { transaction in
                transaction.author == author
            }
        }

        return try context.fetchHistory(descriptor)
    }

    private func tripChanges(
        in transactions: [DefaultHistoryTransaction]
    ) -> Batch {
        var batch = Batch()

        for transaction in transactions {
            for change in transaction.changes {
                switch change {
                case .insert(_ as DefaultHistoryInsert<Trip>):
                    batch.upsertIDs.insert(change.changedPersistentIdentifier)

                case .update(_ as DefaultHistoryUpdate<Trip>):
                    batch.upsertIDs.insert(change.changedPersistentIdentifier)

                case .delete(let payload as DefaultHistoryDelete<Trip>):
                    if let remoteID = payload.tombstone[\.remoteID] as? String {
                        batch.deletedRemoteIDs.append(remoteID)
                    }

                default:
                    break
                }
            }

            batch.latestToken = transaction.token
        }

        return batch
    }

    private func uploadTripChanges(
        _ batch: Batch,
        using context: ModelContext
    ) throws {
        // Fetch current Trip values for batch.upsertIDs.
        // Send delete operations using batch.deletedRemoteIDs.
        // Only return after the remote work has succeeded.
    }
}

This keeps the roles clean:

  • HistoryObserver tells you that new history exists.
  • eventCounter is the observable signal.
  • ModelContext.fetchHistory() reads the transactions after that signal fires.
  • The saved DefaultHistoryToken keeps the processor from replaying old work, and the loop drains large backlogs in batches.
  • The transaction author keeps app changes, widget changes, and server imports from stepping on each other.
  • Delete handling stays separate from upserts so preserved tombstone values are not lost.

You may still want one catch-up pass when the app launches, especially if the process was not alive when a widget or extension wrote into the store. After that, let eventCounter schedule the normal fetches instead of polling history on a timer.

Pick the right observer

Use sectioned @Query when a SwiftUI view needs grouped model data. Keep the section key queryable, then render _trips.sections or _query.sections directly.

Use ResultsObserver when non-view code needs the current result set. It is ideal for derived state where “recompute from current results” is clearer than “inspect every transaction.”

Use HistoryObserver when you care about durable changes: sync, app extensions, badges, import pipelines, and anything that needs transaction authors or insert/update/delete semantics. Treat the observer as a trigger, then use fetchHistory and your saved token to process the real work.

That is the larger pattern in the WWDC26 additions. SwiftData is giving us more places where the framework can own change tracking. Your app code still decides what the changes mean, but it does not have to rebuild the machinery that notices them.

Further reading: