Video #255: Observation: The Future
Episode: Video #255 Date: Oct 30, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep255-observation-the-future

Description
We’ve explored the present state of observation in Swift, so what’s the future have in store? Currently, observation is restricted to classes, while one of Swift’s most celebrated features, value types, is left out in the cold. Let’s explore a future in which observation is extended to value types.
Video
Cloudflare Stream video ID: 4d1e33711f3e551acaf478688aef3d6d Local file: video_255_observation-the-future.mp4 *(download with --video 255)*
Transcript
— 0:05
But looking at Apple’s most modern sample code leads us to believe that structs are no longer appropriate for such foundational, currency domain types. If we want granular observation for these types and their nestings, then we should convert them to classes and apply the @Observable macro. Stephen
— 0:23
So, this is a pretty big shift in how we are to build SwiftUI applications, and it seems to have kind of flown under the radar. I think we are all so blown away by the power of the @Observable macro that we didn’t pay attention to the fact that we are now spreading reference types all over our applications.
— 0:40
And it turns out that the @Observable macro simply does not work on structs. If you try to apply it to a struct you will instantly be greeted with a compiler error letting you know that the macro currently only works with classes. Brandon
— 0:51
But it wasn’t always this way. In the first few betas of Xcode 15 and Swift 5.9 the macro was allowed to be used on structs. However, there were some quirks with it. The most obvious is that at that time in the early betas the ObservationRegistrar type was not Equatable or Hashable , which meant that you would lose automatic synthesis of those conformances on your structs if you use the macro, and that is something that we should expect to keep working.
— 1:17
This motivated us to open up a discussion on the Swift forums on how the @Observable macro is supposed to work with structs, and boy did that open a can of worms. It turns out that observable structs are a lot more subtle than first meets the eye, and ultimately the core Swift team decided to restrict the macro only to classes for the time being, while confessing that eventually they would like it to work with structs. Stephen
— 1:44
So, we want to spend some time investigating what observable structs would mean if they were possible, why they can be tricky, and what one could do to remedy the situation. This discussion will be a little theoretical since the core team made the decision to not support observable structs, but we will use these ideas when we bring the tools from the Observation framework to the Composable Architecture, since one of the main perks of that library is that you get to build your applications with value types instead of reference types. Observable structs in theory
— 2:15
First let’s see definitively that the @Observable macro cannot be used with structs. If we created a simple little counter state struct with the macro applied: @Observable struct CounterState { var count = 0 }
— 2:35
…you will get the following error: ‘@Observable’ cannot be applied to struct type ’CounterState’
— 2:41
However, macros aren’t magic. All they do is insert extra code into your programs so that you don’t have to. But all of the code inserted are things we could write ourselves, and further we could add it to any kind of type, not just classes.
— 2:54
For example, let’s quickly convert our CounterState to a class: @Observable class CounterState { … }
— 2:58
…so that the @Observable macro works. And then let’s expand all the macros and copy-and-paste all of that code directly into the type: @ObservableState class CounterState { @ObservationIgnored private var _count = 0 var count: Int { @storageRestrictions(initializes: _count) init(initialValue) { _count = initialValue } get { access(keyPath: \.count) return _count } set { withMutation(keyPath: \.count) { _count = newValue } } } @ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar() internal nonisolated func access<Member>( keyPath: KeyPath<CounterState , Member> ) { _$observationRegistrar.access(self, keyPath: keyPath) } internal nonisolated func withMutation< Member, MutationResult >( keyPath: KeyPath<CounterState , Member>, _ mutation: () throws -> MutationResult ) rethrows -> MutationResult { try _$observationRegistrar.withMutation( of: self, keyPath: keyPath, mutation ) } }
— 3:43
Now we can even comment out the @Observable macro because we have decide to just write out all of its code ourselves and not rely on the macro at all: //@Observable class CounterState { … }
— 3:59
And further, we can now convert the type back to a struct: // @Observable struct CounterState { … }
— 4:02
And this compiles. And so this is technically a workaround for the fact that the @Observable macro does not allow structs. It’s not because the code can’t compile for structs. It’s just that the core Swift team has decided to ban structs from using the macro.
— 4:19
But why would they do that?
— 4:21
Well, it’s not immediately clear how observation should behave for value types. Value types are quite different from reference types in that they are meant to be copied around without a care for the world, and each new copy is fully untethered from the value it was copied from. Should copied values share the same registrar as the original value? Or should it get a whole new registrar? And what if you copy a value, make some mutations, and then copy back into the original value? Should the registrars be merged somehow?
— 4:48
These are complex questions, and it’s not immediately clear what the answers are. To explore this a bit, let’s write some tests to see how the withObservationTracking tool behaves when used with this observable struct.
— 5:02
I’m going to copy-and-paste the CounterState struct over to a test file…
— 5:07
…and I am going to get a basic test into place: final class ObservationExplorationsTests: XCTestCase { … func testObservableStruct() { } }
— 5:15
Let’s begin the test by construct some CounterState and then immediately creating a copy: var state = CounterState() var copy = state
— 5:27
Now remember that CounterState is a struct, and so the copy is a completely independent piece of state. Changes to it have no effect whatsoever on state .
— 5:38
But, how does observation behave? Let’s use withObservationTracking twice, once to observe the count in state and again to observe the count in copy : withObservationTracking { _ = state.count } onChange: { } withObservationTracking { _ = copy.count } onChange: { }
— 5:54
And then in each of the onChange closures we could mutate some state in order to assert on when they are called.
— 6:00
We might hope we can just do something like this: let stateChanges: [String] = [] let copyChanges: [String] = [] withObservationTracking { _ = state.count } onChange: { $0.append("State is changing") } withObservationTracking { _ = copy.count } onChange: { $0.append("Copy is changing") }
— 6:27
…however this does not compile because the onChange closures are marked as @Sendable which means we are prohibited from capturing mutable data. The only way to make this code compile is to package up these arrays in some kind of sendable package. You can do this in a variety of ways, such as wrapping the data in a class that uses locks, or wrapping the data in an actor, but we actually open sourced a library a few months ago that provides a type that makes this very easy.
— 6:52
The library is called Concurrency Extras , and we can import it at the top: import ConcurrencyExtras
— 6:57
But we’ll also need to add the dependency to our project.
— 7:05
And now we can use the LockIsolated type to wrap the mutable arrays in a sendable-safe package: let stateChanges = LockIsolated<[String]>([]) let copyChanges = LockIsolated<[String]>([]) withObservationTracking { _ = state.count } onChange: { stateChanges.withValue { $0.append("State is changing") } } withObservationTracking { _ = copy.count } onChange: { copyChanges.withValue { $0.append("Copy is changing") } }
— 7:43
Now we can start making some assertions. I would hope that if I mutate the state value that the stateChanges array had an element appended, but that the copyChanges array did not: state.count += 1 XCTAssertEqual(stateChanges.value, ["State is changing"]) XCTAssertEqual(copyChanges.value, [])
— 8:07
And then if I mutate the copy , I expect the copyChanges array to have a value appended, and that the stateChanges array did not: copy.count += 1 XCTAssertEqual(stateChanges.value, ["State is changing"]) XCTAssertEqual(copyChanges.value, ["Copy is changing"])
— 8:18
However this fails. The first assertion against copyChanges fails: XCTAssertEqual failed: (”[“Copy is changing”]”) is not equal to (”[]”)
— 8:31
This is happening because the state and copy values are secretly sharing the same observation registrar, which remember contains a reference type deep inside, and therefore mutations made to the state are still going to notify anyone observing the copy value.
— 8:46
So, that seems quite confusing. With value types we are used to being able to make copies of them without a care for the world all thanks to the fact that copies are completely independent from the original value. However, that doesn’t really seem to be true anymore. Mutations to the original state value are being observed by the copy, and that’s really tricky.
— 9:03
But things get even trickier. What if you incremented the copy and then wholesale replaced the state with the copy ? Let’s copy-and-paste the existing test and tweak a few things to represent this: func testObservableStruct_AssignCopy() { var state = CounterState() var copy = state let stateChanges = LockIsolated<[String]>([]) let copyChanges = LockIsolated<[String]>([]) withObservationTracking { _ = state.count } onChange: { stateChanges.withValue { $0.append("State is changing") } } withObservationTracking { _ = copy.count } onChange: { copyChanges.withValue { $0.append("Copy is changing") } } copy.count += 1 XCTAssertEqual(stateChanges.value, <#???#>) XCTAssertEqual(copyChanges.value, <#???#>) state = copy XCTAssertEqual(stateChanges.value, <#???#>) XCTAssertEqual(copyChanges.value, <#???#>) } How do we expect the onChange closures to execute in this case?
— 9:38
Well I certainly expect the first two assertions to behave exactly as they did before, such that state ’s onChange does not execute at all but copy ’s does: copy.count += 1 XCTAssertEqual(stateChanges.value, []) XCTAssertEqual(copyChanges.value, ["Copy is changing"])
— 9:48
Further, once we assign copy to state I would hope that then somehow state ’s onChange is executed because then it is true that count was mutated on state , just in a roundabout way: state = copy XCTAssertEqual(stateChanges.value, ["State is changing"]) XCTAssertEqual(copyChanges.value, ["Copy is changing"])
— 10:04
Now this test of course fails, but I think this is definitely a property we would want to hold with observable structs.
— 10:19
So, this is showing that naively allowing structs to use the machinery from the Observation framework is extremely tricky. Out of the box is does not have the behavior we would expect. And you might think that this is a perfect use case for copy-on-write semantics. After all, by applying the @Observable macro to a struct we are secretly nestling a reference type deep inside, and so perhaps we can check if that reference type is uniquely referenced when mutating, and if it is not we can make a copy of the reference type.
— 10:55
In fact, Philippe Hausler, the Apple engineer who was the one that originally pitched the Observation framework, created a proof-of-concept of this copy-on-write mechanism:
— 11:03
When making a mutation to a struct it checks if the struct’s underlying Extent , which is the reference type held inside the observation registrar, is uniquely referenced, and if not makes a whole new one.
— 11:16
Ultimately it was decided that this is not quite the right direction to go. And in fact, it would not even help with any of the situations presented in these tests. Making a whole new extent certainly prevents mutations of the copy from notifying observations of the state variable, but it also prevents observations of the copy variable too. After all, it’s a whole new extent, and hence all of its internal state has been cleared. Observable structs in practice
— 11:41
It turns out that Swift simply does not have the power to express a truly observable struct in the full abstract. The language needs extra capabilities to track some kind of notion of “location” of values. Reference types have a natural sense of location because it is simply the location of the raw memory that holds the value, and passing around duplicate references to the same object all share the same memory locale. This is why observation works so well for reference types right out of the box without doing any extra work.
— 12:09
But the same is not true of value types. If you copy a value type it gets a new location, and Swift code needs to somehow be able to detect this new location so that it can properly create a new observation registrar that is disconnected from the original. But this kind of location tracking is simply not part of the Swift language model, and so basically just is not possible. Brandon
— 12:28
And that seems sad, but also, things that seem impossible at first has never stopped us before on Point-Free 🙂
— 12:35
Just because Swift doesn’t natively have a concept of location tracking of value types it doesn’t mean we can’t emulate it in very specific situations. Let’s try making use of this naive observable struct in a SwiftUI view, see what goes wrong, and see how to fix it.
— 12:54
Let’s copy-and-paste the contents of CounterView.swift into a brand new file, CounterView_StructState.swift.
— 13:00
And we will replace the observable model class with the CounterState observable struct we previously created:
— 13:27
We’ll also rename the view: struct CounterView_StructState: View { … }
— 13:34
…and hold onto the CounterState as a @State variable rather than holding onto the model: @State var state = CounterState()
— 13:48
And just to simplify things, we will also get rid of all the timer functionality and instead just focus on the incrementing and decrementing functionality: var body: some View { let _ = Self._printChanges() Form { Section { Text(self.state.count.description) Button("Decrement") { self.state.count -= 1 } Button("Increment") { self.state.count += 1 } } header: { Text("Counter") } } }
— 14:12
And we’ll set up a preview: #Preview { CounterView_StructState(state: CounterState()) }
— 14:30
OK, this compiles, and the increment and decrement buttons do work, but does this view actually behave in the way we have come to expect from observable values? That is, does it re-compute its body the minimal number of times based on what data is accessed in the body and what data is mutated in the struct?
— 15:01
Well, to test this, let’s add some extra state to the struct: struct CounterState { var text = "Hello" … }
— 15:07
We’re not even going to go through the steps to implement the get and set accessors on this property for notifying observers of changes. So, as far as SwiftUI is concerned, this field is completely un-observed.
— 0:00
And then we will display this text in the view, but we will stop displaying the count: Form { Section { Text(self.state.text) Button("Decrement") { self.state.count -= 1 } Button("Increment") { self.state.count += 1 } } header: { Text("Counter") } }
— 15:29
So I would hope that when we increment and decrement the count that the body of the view does not even need to re-compute because it is not using the count at all. However, that is not the case: CounterView_StructState: _state changed. CounterView_StructState: _state changed. CounterView_StructState: _state changed. CounterView_StructState: _state changed.
— 15:50
The view is re-computing every time a button is tapped. This is happening because of how @State works. If it holds onto a value type, then it will invalidate the view with every mutation to the value. It doesn’t matter if we are trying to instrument the struct with more fine-grained control using the access and withMutation tools. At the end of the day @State sees that the value is mutated and has no choice but to invalidate the view.
— 16:26
We can prevent @State from seeing the mutations made to the state at large, and allow the view to only be notified of the granular changes inside the struct by wrapping it up in a reference type. That may seem to go against the grain of our desire to use value types instead of reference types, but this reference type will be a little different from our other reference types. It doesn’t hold a bunch of independent pieces of state that represent our feature, but rather it serves only as a wrapper around a single piece of state: the CounterState : class CounterStateStore { var state: CounterState init(state: CounterState) { self.state = state } }
— 17:06
We could even make the state have a private(set) : private(set) var state: CounterState
— 17:09
…thus making it impossible for the outside to mutate this value, and only allowing methods defined on the class to have mutable access to state.
— 17:17
Next we can update the view to hold onto a store of the counter state: struct CounterView_StructState: View { @State var store = CounterStateStore( state: CounterState() ) … }
— 17:29
And use the store inside the view Form { Section { Text(self.store.state.text) Button("Decrement") { self.store.state.count -= 1 } Button("Increment") { self.store.state.count += 1 } } header: { Text("Counter") } }
— 17:39
Now when we run the preview it does behave how we expect. Tapping the increment and decrement buttons does not cause the view to re-compute because the view doesn’t touch the count field at all.
— 17:57
But if we do start accessing that state in the view: Text(self.store.state.count.description) …then the view does start to re-render when the count changes.
— 18:06
So this works exactly as we would hope. When not observing the count the body of the view will not be re-computed, but as soon as we flip on the toggle it starts re-computing with each button tap.
— 18:16
It may not seem like much, but this is actually pretty incredible and is the exact opposite of what we witnessed before when discussing gotchas in the Observation framework. Previously we noted that the granularity of observation was only as fine as the application of the @Observable macro to classes. This meant that if you held a big struct value with dozens of fields in your @Observable model, and accessed a single field of the struct in the view, then you will have unwittingly observed all changes to the struct, not just the one field you are interested in.
— 18:51
But somehow we have gotten around that problem here. We have a reference type holding onto a single struct, and it is somehow observing only changes to the fields in the struct that are used in the view. That seems to be exactly what we want.
— 19:07
But, it’s still not quite correct. If we add a button to the view that “resets” the store’s state by wholesale replacing it with a new CounterState value: Button("Reset") { self.store.state = CounterState() }
— 19:18
This causes some very interesting behavior. The increment and decrement buttons still work as before, but when we tap the “Reset” button nothing happens. The count doesn’t go back to 0, and even worse the increment and decrement buttons no longer work.
— 19:33
What is going on?
— 19:34
Well, because the CounterStateStore class is not observable, the replace of state was not observed at all. That’s why the count in the UI did not reset to 0 when the button was tapped. Further, since a whole new CounterState was assigned, there’s a fresh new observation registrar being used under the hood, and that registrar is completely disconnected from the last time the SwiftUI view invoked withObservationTracking . Remember there’s a withObservationTracking somewhere behind the scenes of our counter view, and that invocation hooked up observation with the previous observation registrar.
— 20:15
So, once the old registrar was discarded and a new registrar created we completely broke observation in this view. The only way to recover it is if something else invalidates the view, such as the parent re-rendering, or some other piece of state in the view changing, because that would cause a fresh invocation of withObservationTracking , allowing the view to hook into the new observation registrar. We can see this by switching the toggle off and on, and we will now see the count is 0 and increment and decrement now works again.
— 20:33
We can see this more concretely by introducing some local @State to the view: @State var isCountObserved = true …that controls whether or not we are displaying the count : if self.isCountObserved { Text(self.store.state.count.description) } … Toggle(isOn: self.$isObservingCount) { Text("Observe count") }
— 20:57
We can see incrementing and decrementing working just fine, but when we tap “Reset” the count remains and incrementing and decrementing do nothing, but if we toggle the observation off and on that’s enough to get the view to re-observe the counter state.
— 21:31
So, it’s not quite right yet, but we are getting close. We just need to observe changes to the state held in the root of the CounterStateStore . We might be tempted to just mark the whole thing as @Observable : @Observable class CounterStateStore { … }
— 21:43
After all, we do want to observe the state right? Well, unfortunately this is too simplistic.
— 21:48
If we run the preview it does visually behave correctly. All the buttons work, even “Reset”. However, we are also getting too many renders. If we turn of observation of the count, then we will see that the view re-renders with each change of the count, even though that data is not being shown in the view at all.
— 22:13
And the reason is due to applying the @Observation macro naively. It will listen for any change to state and notify all observers immediately. But we don’t actually care about all kinds of mutations to state , we only care about a single kind of mutation: the wholesale replacement of state with a new value. That is the only mutation we want to observe so that the “reset” functionality works.
— 22:54
How can we do this?
— 22:55
Well, there is actually a tool in Swift that allows you to detect when a field of a type is being mutated in place versus being wholesale replaced. It is called the _modify accessor, and it sits right alongside the get and set accessors that have been in Swift since the beginning, as well as the init accessor that is new in Swift 5.9 and that we discussed earlier in the episode.
— 23:18
Let’s see how we can use it to distinguish between modifying a value versus wholesale replacing a value. We are going to start by manually performing what the @Observation macro does to the state field in the class because we want to observe it, but we want to tweak how the observation is done.
— 23:31
So, we will change the state variable to be underscored and ignored from observation tracking: @ObservationIgnored private var _state: CounterState
— 23:48
And then we will make state into a computed property where the get and set accessors call out to access and withMutation : var state: CounterState { get { self.access(keyPath: \.state) return self._state } set { withMutation(keyPath: \.state) { self._state = newValue } } }
— 24:13
Right now this is exactly the code that the @Observation macro would write for us, but we are going to make one small tweak. The _modify access can be implemented: _modify { }
— 24:27
…and it is invoked whenever the state is mutated in place. For example, in the view when we do this: self.store.state.count += 1
— 24:34
…that will call _modify since we are mutating it in place.
— 24:38
And we can implement this access by just yielding the mutable value of _state : _modify { yield &self._state }
— 24:43
That’s all it takes. And crucially we are not calling withMutation in the _modify because as far as we are concerned it is not a mutation of state . Something inside state is mutating, and somewhere deep inside the state a withMutation will be called on some child registrar, but at the level of the counter store there was not mutation to state. The only time we notify observers of a mutation to state is when we wholesale replace the entire state value.
— 25:00
Now, it may worry you a bit that this accessor is underscored. This tool has been around for many years, and it is effectively set in stone now since it is used by the standard library and even some of Apple’s open source libraries such as swift-collections, so we think it is safe to use, and there are plans to make it an official language feature someday in the future.
— 25:19
With that one small change done things work how we’d hope. The increment, decrement and reset buttons all work, but also we are observing only the minimal amount of state. So we have fixed the problem, and we have shown how one can theoretically use “observable” structs in SwiftUI. We have modeled all of the state as a single struct, allowing us to embrace all of the power of value types for our feature’s domain, while at the same time we can observe the bare essentials in the view. If our view touches a field of the struct it will be observed, and otherwise it will not.
— 27:02
Now, we did have to perform a lot of ad hoc work to accomplish this. We had to eschew the @Observable macro since it does not work on structs and instead manually expand all of that code ourselves. And then we had to create a reference type wrapper around the struct so that we could anchor its “location” somewhere, and even need to implement custom get / set / _modify logic under the hood to minimize observation.
— 27:30
So, it was a lot of manual work, but also, a lot of it could be genericized and packaged up into a library. First of all, a macro could be made that mimics exactly what the @Observable macro does, but allows it to be applied to structs. In fact, one could even copy-and-paste most of the code from the open source Swift project to accomplish this.
— 27:56
And second of all, a generic Store class could be made that wraps observable structs, and implements the custom get / set / _modify accessors so as to minimize observation. Such a Store class might even be able to implement “scoping” operations that behave similarly to how scoping works in the Composable Architecture, and you would essentially have a nice little library that allows you to use value types in SwiftUI applications in a way that places nicely with the Observation framework. Conclusion
— 28:37
There are still a ton of questions to answer to implement such a library, but it is theoretically possible. However, we aren’t going to spend anytime doing that right now, and instead we are going to finish this series of episodes.
— 28:49
We have spent quite a few episodes diving deep into the new Observation framework. We have seen what tools were available to us pre-iOS 17 for observation, and then saw how the Observation framework improved on those tools in nearly every way imaginable.
— 29:06
Stephen : Then we took a peek under the hood. We saw what code was being expanded by the macro, we looked at the actual open source code powering the Observation framework in order to understand roughly how it works, and then we discussed a number of gotchas. While the Observation framework is quite amazing, it is not without its quirks, and it is imperative you understand how the framework behaves to avoid those pitfalls.
— 29:30
Brandon : And then finally we tried to theorize how observable structs might look like. The Observation framework took a specific stance to not allow observable structs, but that doesn’t mean can’t try to do it ourselves. We saw that value types bring significant complexities to observation, and those problems cannot be solved by Swift in general, but it is possible to use observable structs in certain constrained situations. It was pretty interesting to see, and those ideas are going to form the basis of how we bring observation tools to the Composable Architecture.
— 30:01
Stephen : And that will be something we discuss very soon, but before doing that we want to spend one more episode showing how the new Observation tools can be used in a real world code base. We are going to take the Standups application that we built a few months ago in our “Modern SwiftUI” series, and refactor it to get rid of all observable objects and instead use plain Swift classes adorned with the @Observable macro.
— 30:26
Until next time! Downloads Sample code 0255-observation-pt4 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .