SwiftData in iOS 27: Observation and History

SwiftData shipped in iOS 17 with two ways to watch your data: @Query inside a SwiftUI view, or manual notification plumbing on ModelContext for everything else. Neither covered the case that matters most for a synced app, which is knowing when another device changed the store. iOS 27 closes both gaps in one release. ResultsObserver makes change tracking a first-class object you can hold outside a view, and HistoryObserver turns remote-change notifications into a structured stream you observe like any other model. iOS 27 adds observation as a primitive, not a SwiftUI side effect.1

The framing matches the rest of this cluster: SwiftData was cheap to start and expensive to coordinate. Watching a fetch outside the view hierarchy meant rebuilding what @Query does by hand. Reacting to a CloudKit push meant subscribing to a raw remote-change notification and reconciling history tokens yourself. iOS 27 gives both jobs a named type that conforms to Observable, so the same SwiftUI update machinery that already drives your views drives your sync layer too.

TL;DR / Key Takeaways

  • ResultsObserver observes and tracks changes to a collection of persistent models in a model context, providing real-time updates when the underlying data changes. It is Observable, so a SwiftUI view updates automatically, and it works outside a view where @Query cannot reach.2
  • HistoryObserver monitors a model container’s data stores for remote changes and notifies when new history transactions are available, listening for the container’s remote-change notifications. It is the structured answer for CloudKit and multi-process sync.3
  • @Attribute(.codable) uses the property’s codable representation to store the property, giving you a declarative way to persist a Codable value type without a ValueTransformer.4
  • All three ship across iOS, iPadOS, macOS, Mac Catalyst, tvOS, visionOS, and watchOS in the 27.0 beta.234

Watching A Fetch Outside The View: ResultsObserver

@Query is excellent and constrained. It lives in a SwiftUI view, it re-runs when its predicate changes, and it hands the view an array. The constraint is the location. A view model, a sync coordinator, an export job, or a background reconciler has data that changes and no @Query to lean on. Before iOS 27 those callers subscribed to ModelContext save notifications and re-fetched by hand, which is the manual reconstruction of exactly what @Query already does internally.

ResultsObserver is iOS 27’s named answer. The declaration states what it is:2

final class ResultsObserver<Element, SectionName> where Element : PersistentModel, SectionName : Hashable

The class automatically monitors changes to models that match specified fetch criteria and maintains a collection of fetched results, which makes it the tool for keeping any consumer synchronized with persistent data, not only a view.2 You configure it two ways: with a complete FetchDescriptor, or with individual filter predicates and sort descriptors.2 The two SectionName paths matter for grouped lists; when you do not need sectioning, you pass Never as the SectionName type parameter.2

The payoff over the old manual approach is the Observable conformance. ResultsObserver is Observable, which lets SwiftUI views automatically update when results change, the same way @Observable model objects drive view invalidation (covered in @Observable internals).2 A sync coordinator that holds a ResultsObserver gets change notifications without writing a single NotificationCenter line, and any view that reads the observer’s results re-renders for free.

In session 274, Apple introduces ResultsObserver for the case @Query cannot serve, fetching and observing the store from anywhere in your app through Swift Observation, including a state object or a game that never touches SwiftUI.5

Watch: What's new in SwiftData (WWDC26) ResultsObserver brings query-style observation to code outside SwiftUI views.

import SwiftData
import Observation

@Observable
final class ShoppingListModel {
    let observer: ResultsObserver<ShoppingItem, Never>

    init(context: ModelContext) {
        let descriptor = FetchDescriptor<ShoppingItem>(
            sortBy: [SortDescriptor(\.sortOrder)]
        )
        // No sectioning, so SectionName is Never.
        observer = ResultsObserver(context: context, fetchDescriptor: descriptor)
    }
}

Apple’s published reference confirms the class declarations and configuration surface (a FetchDescriptor or filter predicates and sort descriptors, Never for the section name when you do not section) but elides the exact initializer signatures at the time of writing, so treat the call shapes in these examples as illustrative and confirm the parameter labels against the SDK.

The mental shift is that the fetch becomes an object you own and pass around, rather than a property wrapper trapped in a view’s body. A @Query answers “what does this view show?” A ResultsObserver answers “what is the current state of this fetch, wherever I am holding it?” The second question is the one a non-view caller actually asks.

Reacting To Other Devices: HistoryObserver

The gap @Query and ResultsObserver both leave open is the remote change. When CloudKit pushes a record your iPhone edited down to your Mac, or a second process writes to a shared store, the local context did not make the change, so a local fetch observer has nothing to react to until the next sync round-trip surfaces the rows. The raw signal is a remote-change notification on the container, and reconciling it by hand means tracking history tokens and replaying transactions yourself.

HistoryObserver is the structured answer:3

final class HistoryObserver

The class monitors a model container’s data stores for remote changes and notifies when new history transactions are available.3 It automatically listens for the container’s remoteChange notifications and determines whether the incoming changes are relevant based on the models you specify at initialization, so you scope it to the model types you care about rather than waking on every store mutation.3 You use it as an @Observable object and react to its changes from a SwiftUI view or another observer, which keeps the sync reaction in the same reactive idiom as the rest of your app.3

The detail that keeps it cheap is history-token tracking. The observer records its position in each data store’s transaction history using history tokens, which lets it process only the new transactions since the last check rather than re-scanning the whole store.3 Incremental processing is the difference between a sync handler that stays fast as history grows and one that re-reads everything on every push.

import SwiftData

// Scope the observer to the model types whose remote changes matter.
let historyObserver = HistoryObserver(
    container: modelContainer,
    observedModels: [ShoppingItem.self]
)

The CloudKit scenario is where the observer earns its place. An app using SwiftData’s CloudKit integration already receives remote changes; the question was always how cleanly your code learns about them and how much of the store it has to re-examine to act. HistoryObserver answers both: it filters to relevant models, it advances per-store tokens, and it surfaces the result as an observable property your reconciliation code reads. The multi-device sync patterns the apps in this cluster solved with hand-rolled iCloud plumbing (see SwiftData schema discipline) get a framework seam to hang on.

Persisting A Value Type Cleanly: @Attribute(.codable)

The third addition is small and practical. SwiftData stores Swift’s primitive types and @Model relationships natively, but a property whose type is a custom Codable value (a struct holding a few fields, an enum with associated values) needed a ValueTransformer and the .transformable(by:) attribute, which is Core Data ceremony leaking back through the macro surface.

iOS 27 adds the codable storage option:4

static var codable: Schema.Attribute.Option { get }

The option uses the property’s codable representation to store the property, so a Codable value type persists through its own Encodable/Decodable conformance with no transformer to register.4 You apply it the same way as any other attribute option:

import SwiftData

struct Coordinate: Codable {
    var latitude: Double
    var longitude: Double
}

@Model
final class Place {
    var name: String

    // Persisted via Coordinate's own Codable conformance.
    @Attribute(.codable) var location: Coordinate
}

The practical rule is to reach for .codable when a property is a self-contained Codable value that does not deserve its own @Model table. A coordinate pair, a small settings struct, an enum with payload: these are data, not entities, and .codable stores them inline through the representation they already define instead of forcing a transformer or an artificial relationship.

When To Reach For Each

The three additions answer three different questions, and the question tells you which one to use.

  • Reach for ResultsObserver when a non-view caller needs a live fetch. A view model, a coordinator, an export task, anything that has data that changes and is not a SwiftUI body. Inside a view, @Query is still the lighter tool; the observer earns its place the moment the consumer is not a view.2
  • Reach for HistoryObserver when changes arrive from outside the local context. CloudKit sync, a second process, a share extension writing to the same store. A local fetch observer does not see the change until rows surface; the history observer sees the transaction and tells you which models moved, incrementally.3
  • Reach for @Attribute(.codable) when a property is a Codable value, not an entity. Small structs and enums that travel with their owner. If the type needs its own identity, relationships, or queries, it wants @Model instead; if it is just inline data, .codable skips the transformer.4

The two observers compose. A ResultsObserver keeps your in-process fetch live; a HistoryObserver tells you when a remote push warrants acting on what changed. An app doing real multi-device sync uses both, and uses .codable to keep its value-type columns honest along the way.

FAQ

How is ResultsObserver different from @Query?

@Query is a SwiftUI property wrapper that lives inside a view and feeds that view an array. ResultsObserver is a standalone class you create and hold anywhere, including outside the view hierarchy, that observes and tracks changes to a collection of persistent models in a model context.2 Because the observer is Observable, a SwiftUI view that reads it still updates automatically, so it covers both the in-view case and the view-model or coordinator case that @Query cannot reach.2

What does HistoryObserver actually listen to?

It monitors a model container’s data stores and listens for the container’s remote-change notifications, then decides whether the incoming changes are relevant based on the models you name at initialization.3 When relevant transactions arrive it notifies you that new history is available, and it tracks its position per store with history tokens so it processes only new transactions since the last check.3 That makes it the structured handler for CloudKit sync and multi-process writes rather than a local edit.3

Can I use ResultsObserver and HistoryObserver together?

Yes, and a synced app usually should. ResultsObserver keeps an in-process fetch current as the local context changes; HistoryObserver surfaces changes that originate remotely, scoped to the model types you specify.23 Both are observable objects you can react to from a SwiftUI view or another observer, so they slot into the same reactive flow without separate notification handling.23

When should I use @Attribute(.codable) instead of a relationship?

Use .codable when the property is a self-contained Codable value type that has no independent identity, since the option stores the property through its own codable representation.4 Use a @Model relationship when the value is a real entity with its own lifecycle, identity, or queries. The dividing line is whether the thing is data that belongs to its owner, or an entity that other rows reference.

The full Apple Ecosystem cluster: SwiftData schema discipline for the migration cost that observation sits on top of; the SwiftData migrations guide for the VersionedSchema and MigrationPlan machinery; @Observable internals for the observation model these classes plug into; SwiftUI internals for the framework substrate underneath. The hub is at the Apple Ecosystem Series. For broader iOS-with-AI-agents context, see the iOS Agent Development guide.

References


  1. Apple Developer Documentation: SwiftData. The framework reference covering @Model, ModelContext, ModelContainer, queries, and the iOS 27 observation additions. 

  2. Apple Developer Documentation: ResultsObserver (iOS 27.0 beta). “Observes and tracks changes to a collection of persistent models in a model context.” Declared as final class ResultsObserver<Element, SectionName> where Element : PersistentModel, SectionName : Hashable; configurable with a FetchDescriptor or with filter predicates and sort descriptors; Observable, so SwiftUI views update automatically; pass Never as SectionName when no sectioning is needed. 

  3. Apple Developer Documentation: HistoryObserver (iOS 27.0 beta). “Monitors a model container’s data stores for remote changes and notifies when new history transactions are available.” Declared as final class HistoryObserver; listens for the container’s remoteChange notifications, scopes relevance via the observedModels you specify, used as an @Observable object, and tracks position per store with history tokens for incremental processing. 

  4. Apple Developer Documentation: codable (iOS 27.0 beta). “Uses the property’s codable representation to store the property.” Declared as static var codable: Schema.Attribute.Option { get }

  5. Apple, WWDC26 session 274, What’s new in SwiftData. Apple introduces ResultsObserver, which “fetches data from your SwiftData store and then observes your store for changes” but “works anywhere in your app — independent of SwiftUI views — using Swift Observation,” and names a state object or a game written in SceneKit as cases @Query cannot reach. 

相關文章

SwiftData Migrations: Lightweight vs Custom, And When You Don't Need a V2

SwiftData's migration model uses VersionedSchema, MigrationStage, and SchemaMigrationPlan. Most schema changes don't nee…

13 分鐘閱讀

SwiftData's Real Cost Is Schema Discipline

SwiftData's API is two macros. The cost is what happens after you ship. Optional fields are the cheap migration; non-opt…

15 分鐘閱讀

Your Agent Has Two Untrusted Inputs

AI agents have two untrusted inputs: code the model writes and tool output it reads. One now has a real WASM sandbox; th…

12 分鐘閱讀