The Observation framework provides a macro based implementation of the observer design pattern. It’s designed to be used with SwiftUI and available in the Swift standard library since iOS 17.
Usage
To create an observable class annotate it with the macro @Observable. Note that this macro can only be used with classes, not structs or other types.
importObservation@ObservablefinalclassPerson{varname:String// no need for @Published!}
To observe changes from SwiftUI no action is needed, just add a reference to the object:
importSwiftUIstructPersonView:View{letperson:Person// no need for @State!varbody:someView{Text(person.name)// will refresh when name changes}}
Note that in this example we were using a reference passed from a parent view.
Whether the parent uses @State or not, the child keeps using let. Note that if you add @State to the child, it will preserve the instance that is first passed. That is, the child won’t observe the instance change performed by its parent. Usually you want parent and child to share state so this is generally a mistake.
The child only needs changes if it is going to change the properties of the instance held by its parent. In this case we use @Bindable in the child, but note we don’t use $ in the parent, only in the child.
To change properties of an instance you don’t own use @Bindable:
structPersonView:View{@Bindableletperson:Person// to change properties of the parentvarbody:someView{TextField("Name",text:$person.name)}}structParentView:View{@Stateletperson=Person(name:"Alice")varbody:someView{PersonView(person:person)// do NOT use $ here}}
There are other esoteric ways to perform property changes on a parent:
structPersonView:View{@Environment(Person.self)varperson:Personvarbody:someView{// creating a @Bindable at the top of your body@Bindablevarperson=self.personTextField("Name",text:$person.name)}}structPersonView:View{@Environment(Person.self)varperson:Personvarbody:someView{// instantiating a Binding struct inlineTextField("Name",text:.init(get:{person.name},set:{person.name=$0}))}}
To pass a @Bindable in an init use the same format as with @State:
Finally, here is a copy paste-able example to play with.
Parent/child relationship
Copy this code in a fresh project to get the following:
importObservationimportSwiftUI@ObservablefinalclassPerson{varname:String// no need for @Published!init(name:String){self.name=name}}structPersonView:View{// Use @Bindable to change properties@Bindablevarperson:Personvarbody:someView{VStack(alignment:.leading){Text("Child").font(.headline)Text("person name: \(person.name)")TextField("Name",text:$person.name).textFieldStyle(.roundedBorder)}}}structParentView:View{// Use @State if the instance itself changes@Statevarperson=Person(name:"Alice")varbody:someView{VStack(alignment:.leading){Text("Parent").font(.headline)Text("person name: \(person.name)")Button("change instance"){person=Person(name:"Mallory")}Button("change property"){person.name="Bob"}PersonView(person:person).padding(.top,16)}}}@mainstructPersonsApp:App{varbody:someScene{WindowGroup{ParentView().padding().frame(alignment:.leading)}}}
The New Way to Build Views
Here a summary of the new (iOS 17+) way to build your views:
Use @Observable macro on classes whose properties you need to observe.
Use @State for local independent state.
Use @ObservationIgnored in the properties of an @Observable object to opt-out tracking.
Use @Bindable for two-way binding with Observable classes
Use @Binding for two-way binding with non-Observable types (struct, enum, actor)
The Observation framework does not use @StateObject as several LLM still recommend.
Example.
Property wrapper use
importObservationimportSwiftUI@ObservablefinalclassPerson{varname:String@ObservationIgnoredvarage:Int// won’t be trackedinit(name:String,age:Int){self.name=nameself.age=age}}structPersonEditorView:View{@Bindablevarperson:Person// for two-way binding@StateprivatevarisEditing=false// local statevarbody:someView{VStack(spacing:16){// using local @StateToggle("Enable Editing",isOn:$isEditing)// two-way binding with @BindableTextField("Name",text:$person.name).textFieldStyle(.roundedBorder).disabled(!isEditing)// age is not tracked!Text("Age: \(person.age)").foregroundColor(.gray)// name is trackedText("Current name is: \(person.name)")}.padding()}}structContentView:View{letperson=Person(name:"John",age:30)varbody:someView{PersonEditorView(person:person)}}
Benefits
Property changes automatically refresh the layout, even if the property
is used through a computed property
is nested through several @Observable objects
is outside the view hierarchy (e.g. a global variable or singleton)
is nil or it is a property of an optional object currently set to nil
Changes are only tracked for properties that are involved in the layout. For instance, referencing the property in a button action doesn’t track the property if it’s not being displayed.
withObservationTracking
withObservationTracking is the only programmatic use allowed in this framework. It fires just once, so if you want continuous observation you’ll need a recursive call to re-establishes observation on every change (see below). Example.
importFoundationimportOSLogfuncstringDump(_object:Any)->String{varoutput=""dump(object,to:&output)returnoutput}funcdiffStringDumps(_dump1:String,_dump2:String)->String{letlines1=dump1.components(separatedBy:.newlines)letlines2=dump2.components(separatedBy:.newlines)vardiff=""letmaxLines=max(lines1.count,lines2.count)foriin0..<maxLines{ifi<lines1.count&&i<lines2.count{iflines1[i]!=lines2[i]{diff+="- \(lines1[i])\n+ \(lines2[i])\n"}}elseifi<lines1.count{diff+="- \(lines1[i])\n"}elseifi<lines2.count{diff+="+ \(lines2[i])\n"}}returndiff}@ObservableclassPerson:CustomStringConvertible{varname:Stringvarage:Intinit(name:String,age:Int){self.name=nameself.age=ageobserve()}funcobserve(){letoldDesc=description_=withObservationTracking{// tracking nameself.name}onChange:{// This closure runs when the Observable macro still didn't apply the change.// The async schedules the closure in the next runloop iteration so we can observe the change.DispatchQueue.main.async{letdiff=diffStringDumps(oldDesc,self.description)print(diff)self.observe()}}}vardescription:String{"""
name: \(name)
age: \(age)
"""}}varperson=Person(name:"Alice",age:30)person.name="Bob"RunLoop.current.run(mode:.default,before:Date.init(timeIntervalSinceNow:3))/*
- name: Alice
+ name: Bob
*/
If class has incompatible annotation (like @ModelActor), conform to Observable protocol instead
Example
// Pre-iOS 17classDataStore:ObservableObject{@Publishedvarcount:Int// opt-in to trackletidentifier:UUID// not tracked}// iOS 17@ObservableclassDataStore{varcount:Int// tracked@ObservationIgnoredletidentifier:UUID// opt-out to not track}
Replace @StateObject
Use let to change properties, or @State if you also change the instance itself
@State now handles both value and reference types
Example
// Pre-iOS 17structTodoView:View{// reference type needs @StateObject@StateObjectvarstore=TodoStore()...}// iOS 17structTodoView:View{// @State handles both value and reference types@Statevarstore=TodoStore()...}
Replace @ObservedObject
Use @Bindable for @Observable classes in a child when you need $ syntax
Keep using @Binding for value types
Keep using @Binding for @Model objects
Example
// Pre-iOS 17structTodoView:View{@ObservedObjectvarstore:TodoStore// reference type needs @ObservedObject@Bindingvarcount:Int// value type keeps using @Binding@Bindingvarmodel:TodoModel// @Model types keep using @Binding...}// iOS 17structTodoView:View{@Bindablevarstore:TodoStore// use @Bindable when $ binding needed@Bindingvarcount:Int// value types unchanged@Bindingvarmodel:TodoModel// @Model unchanged...}
// Pre-iOS 17structTodoView:View{@EnvironmentObjectvarstore:TodoStore...}ContentView().environmentObject(todoStore)// iOS 17@ObservableclassTodoStore{// mark it @Observablevartodos:[Todo]...}structTodoView:View{@Environment(TodoStore.self)varstore// replace...}ContentView().environment(TodoStore.self,todoStore)// new injection by type.environment(\.todoStore,todoStore)// new injection by keyPath
A view renders data from the Person Observable object.
Text(person.name)
SwiftUI runtime reads the code, notices it has an observable object, and creates a tracking wrapper similar to this:
withObservationTracking{_=Text(person.name)}onChange:{// schedule this view for refresh (private API)}
During view rendering, the getter for name triggers an access call:
registrar.access(self,keyPath:\.name)
This call records in thread-local storage that this property was accessed during the view's render. Internally, the ObservationRegistrar maintains a mapping linking this specific property (object + keyPath) to the view update function provided in the onChange callback.
When name is modified:
willSet and didSet are called
The registrar looks up all observers (view update functions) registered for this property
Each registered observer is notified, which in this case triggers SwiftUI to schedule the view for refresh using the onChange callback registered during the tracking (step 2).
Thread-safety
The framework is thread-safe to prevent data races, which supports the use of withObservationTracking outside SwiftUI. The ObservationRegistrar uses an internal _ManagedCriticalState which wraps a mutex (using the new Swift 6 Mutex). All state mutations in ObservationRegistrar happen within this critical region:
willSet
didSet
access tracking
registration of observers
A takeaway point is that while mutations are thread-safe, we still need to mutate only on the main thread for objects used in SwiftUI. While SwiftUI might have internal mechanisms to handle updates from a background thread, it’s not documented or guaranteed. The safer approach is to mutate Observable properties on the main thread, especially when they drive UI updates.
The beginning of a general framework
…the current implementation of Observable was pretty much tailor made for SwiftUI (so much so that I wonder if even withObservationTracking(_:onChange:) should have been under SPI...) Anyway, the plan is to enhance it for more general use-cases – but that hasn't happened yet. -source
Looking at the private enum ObservationKind we see several events:
ValuesObserver seems designed to implement specialized patterns. Example.
Streaming changes
The following example doesn’t compile because certain Observation APIs aren’t public. Nonetheless, it illustrates how we could listen for changes on arbitrary objects.
letsensor=TemperatureSensor()lethistory=TemperatureHistory()// A possible use: // custom storage for changes of an arbitrary object.sensor._$observationRegistrar.context.registerValues(for:[\.currentTemp],storage:history)sensor.currentTemp=20.5sensor.currentTemp=21.0sensor.currentTemp=21.5// Object being tracked@ObservableclassTemperatureSensor{varcurrentTemp:Double=0}// Custom storageclassTemperatureHistory:ValueObservationStorage{privatevarreadings:[Double]=[]privateletmaxReadings:Intinit(maxReadings:Int=10){self.maxReadings=maxReadings}overridefuncemit<Element>(_element:Element)->Bool{guardlettemp=elementas?Doubleelse{returnfalse}readings.append(temp)ifreadings.count>maxReadings{readings.removeFirst()}print("New reading: \(temp)°C")print("History: \(readings)")returnfalse// false = keep observing}overridefunccancel(){print("Stopped temperature monitoring")readings=[]}}
Third party implementations
If we look at the swift-perception from Pointfree the mechanism is very similar to Apple’s. However, not having access to the SwiftUI internal magic, clients need to do the wrapping themselves calling WithPerceptionTracking.
ObservationBP is another implementation which replicates Apple’s very closely. It uses this ObservationView.swift wrapper. The refresh is forced through a change in local @State, same trick used by WithPerceptionTracking.
Troubleshooting
'@Observable' cannot be applied to actor type
Actors are designed to protect their mutable state through actor isolation, ensuring that only one task can access the actor’s state at a time. But the Observation framework is designed to work synchronously and notify inmediately. If you want to mix both models you have to
Store the state to observe in a @Observable type, then store this type inside the actor.
Or store the actor in an @Observable type, call it asynchronously, then update state with the result.
Value must conform to Observable
Two common reasons for this:
Primitive values don’t conform to Observable.
Optionals are not not Observable either, even when the wrapped type is.
// error: primitives are not Observable@Bindablevarcontent:String// error: 'init(wrappedValue:)' is unavailable: // The wrapped value must be an object that conforms to Observable@Bindablevarfoo:ChallengeEditingViewModel?// works: ChallengeEditingViewModel is Observable and not optional@BindablevarviewModel:ChallengeEditingViewModel
If you need your data to be optional, consider creating an enum with two states, one of them being the @Observable class that you need to track.
Invalid redeclaration of synthesized property
Problem: @Observable and @Bindable they both try to generate a backing storage property. This happens when we want to track and modify properties on a view model.
@ObservablefinalclassChallengeEditingViewModel{@Bindablevarchallenge:ChallengeDetail// Invalid redeclaration of synthesized property '_challenge'
Solution: If you need @Bindable also add @ObservationIgnored.
Observation will still work as demonstrated in this example.
Example of @ObservationIgnored @Bindable
Updates to the parent and child view happen with each press of the counter, even when the viewmodel uses @ObservationIgnored @Bindable.
importSwiftUI@ObservableclassCounter{varcount:Int=0}@ObservableclassTestViewModel{@ObservationIgnored@Bindablevarcounter:Counterinit(counter:Counter){self.counter=counter}}structParentView:View{letviewModel=TestViewModel(counter:Counter())varbody:someView{VStack(spacing:20){Text("Counter: \(viewModel.counter.count)")Button("Increase"){viewModel.counter.count+=1}ChildView(viewModel:viewModel)}.padding()}}structChildView:View{letviewModel:TestViewModelvarbody:someView{VStack{Text("Child View")Text("Counter in child: \(viewModel.counter.count)")}.padding().background(Color.gray.opacity(0.2)).cornerRadius(8)}}
Note that in this example let viewModel didn’t need @Bindable. If Counter had been added directly to the view, it would need @Bindable to track the changes, like in the code below. However, it is not the case if the view owns an Observable reference object that owns another reference object where the state is.
structChildView:View{// @Bindable needed because we bind directly to a Counter@Bindablevarcounter:Counter...
The key distinction is:
When a view directly owns an @Observable object it needs @Bindable
When a view owns an @Observable object that contains other @Observable objects it doesn't need @Bindable