EP 252 · Observation · Oct 9, 2023 ·Members

Video #252: Observation: The Past

smart_display

Loading stream…

Video #252: Observation: The Past

Episode: Video #252 Date: Oct 9, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep252-observation-the-past

Episode thumbnail

Description

It’s time to dive deep into Swift’s new observation tools. But to start we will take a look at the tools SwiftUI historically provided, including the @State and @ObservedObject property wrappers, how they behave and where they fall short, so that we can compare them to the new @Observable macro.

Video

Cloudflare Stream video ID: d94181a042ef68bc7006c32000ac0866 Local file: video_252_observation-the-past.mp4 *(download with --video 252)*

Transcript

0:05

One of the more exciting things announced at WWDC this year was the new “observation” tools that gives some us some machinery to observe how specific parts of a system change. This was exciting for a few reasons:

0:18

First of all, this machinery greatly improves one of the biggest pain points of SwiftUI, and that is understanding how changes to your model cause views to re-compute their bodies. Previously it was on you to make sure you marked the fields that are used in the view with the @Published property wrapper. If you did not then it would be possible for state to change in your model without your view updating, which is a bad bug. Or if you mark too many things with @Published you run the risk of re-computing your views too often. Stephen

0:47

Second, this new Observation framework is built directly into the open source Swift language and not as a proprietary Apple framework. This means that theoretically one can leverage the power of observation for many things outside of SwiftUI, and even outside of the Apple ecosystem, such as on Linux, Windows and more. Brandon

1:06

And third, and what’s really exciting for us personally, is that the new Observation framework has allowed us, meaning me and Stephen, to re-evaluate nearly every assumption we made when we originally built the Composable Architecture. It provides us the perfect opportunity to massively simplify and slim down the core library. We are able to completely get rid of concepts such as the “view store”, and we can get rid of a zoo of custom view types and modifiers, such as the ForEachStore , IfLetStore , SwitchStore , sheet(store:) and more. Stephen

1:46

It’s really exciting stuff, but before we can dive into how this all affects the Composable Architecture, we need to take a step back and really understand the Observation framework. We want to take a few episodes to really dig deep into the topic:

1:58

We’re going to start by recalling what are the observation tools provided to us in pre-iOS 17, such as the @State and @ObservedObject property wrappers, and discuss what their drawbacks were. Brandon

2:10

Then we will show how the new Observation framework improves upon nearly every aspect of those old tools, and we will even dig into the actual code in the open source Swift repo to understand how the tools work. Stephen

2:23

Then we will show that although the new Observation framework is pretty fantastic, it does have some gotchas lurking in the shadows. If you are not intimately familiar with how the tools work you can easily find yourself observing far more state than you expect. Brandon

2:37

And then we will end the series by pushing the Observation framework to its limit. While the framework is mostly designed to be used with reference types, we will explore what it would mean to apply the machinery to value types. There are some complications to doing this, but it is worth doing this exploration because it will be incredibly important for when we integrate the Observation framework into our popular Composable Architecture library, because one of its most celebrated features is that it allows you to build you eschew reference types and build your features with simple value types.

3:10

We’ve got a lot to cover, so let’s dig in! Observing with @State

3:15

The new Observation framework can be thought of as a pretty general-purpose tool for observing changes to a system. But let’s not kid ourselves. Its creation was almost entirely motivated by SwiftUI, and its initial incarnation is mostly only useful for SwiftUI. During the early stages of the pitch for observation there were many tools proposed that would have made the tools far more applicable outside of SwiftUI. However, those tools were mostly axed by the time the proposal was accepted since they needed a bit more work.

3:45

And because of that, we are going to use SwiftUI as the primary example in order to understand what problem Observation is trying to solve. We have a fresh project created that starts with a nearly blank view, and I want to start adding some data to this UI that also has some logic and behavior.

4:05

By far the easiest way to introduce state into a view is via the @State property wrapper. For example, we can introduce a count to our root content view, and have buttons for incrementing and decrementing it: import SwiftUI struct CounterView: View { @State var count = 0 var body: some View { Form { Section { Text(self.count.description) Button("Decrement") { self.count -= 1 } Button("Increment") { self.count += 1 } } header: { Text("Counter") } } } } #Preview { CounterView() }

4:25

And we’ll need to rename the file and update the entry point of the app.

4:35

The @State property wrapper creates a piece of state that is wholly owned by the view. It will persist for as long as the view lives on the screen, and will not get reset even though the struct representation of the view can be recreated dozens or hundreds of times.

4:53

That sounds pretty unintuitive at first, after all, structs are supposed to be simple, lightweight representations of data, free from behavior. And yet somehow this @State persists across multiple creations of the struct.

5:05

Well, this is all because secretly behind the scenes SwiftUI is keeping track of a shadow world of reference types that represents the true view hierarchy on screen. And those reference types are long-living, even when the corresponding struct version has been recreated and discarded many times. And it’s in those reference types where the value of @State is truly stored, and that is how it can persist inside this struct.

5:36

If we run the preview we will see it works as we expect. Further, if we instrument the body property with _printChanges : var body: some View { let _ = Self._printChanges() … }

5:55

…and run the preview again, we will see that something is printed to the console each time the state is mutated and the view re-renders. CounterView: @self, @identity, _count changed. CounterView: _count changed.

6:08

OK, so nothing too surprising yet, but let’s beef up the behavior in this view. Let’s now add buttons that can start and stop a timer that ticks every second. And with each tick of the timer we will increment a separate piece of state, not the count .

6:23

We’ll add some new state to the view: struct CounterView: View { @State var secondsElapsed = 0 … }

6:25

And we’ll create a new section in the form for the timer: Section { } header: { Text("Timer") }

6:31

In the timer section we will add a button for starting the timer: Button("Start timer") { }

6:35

When this button is tapped we need to start a timer. The simplest way to do this is to create an unstructured task to give us access to an asynchronous context, and then we can simply perform an infinite loop with a sleep: Button("Start timer") { self.secondsElapsed = 0 Task { while true { try await Task.sleep(for: .seconds(1)) self.secondsElapsed += 1 } } }

6:54

Now this is the easiest way to create a timer, but it is not the most robust way. If we cared about accurate timers we need to do more work to accommodate drift in the sleeps, and if we cared about testability we would want to inject a clock into this view. But for now this will do.

7:14

We now have a button for starting the timer, but how can we stop it? Well, to stop an asynchronous unit of work we need to have a handle on it so that we can cancel it. This is done by holding onto a Task in the view as a local state property: struct CounterView: View { @State var timerTask: Task<Void, Error>? … }

7:39

Now when the timer is started we can make sure to cancel any currently inflight timer, and then start a new timer and keep track of the task: Button("Start timer") { self.secondsElapsed = 0 self.timerTask?.cancel() self.timerTask = Task { … } }

7:52

Then we can check whether or not a timer is currently in flight so that we know which button to show. And we’ll even show a little progress indicator next to the stop button to make it very clear that the timer is in progress: if self.timerTask == nil { Button("Start timer") { … } } else { Button { } label: { HStack { Text("Stop timer") Spacer() ProgressView().id(UUID()) } } }

8:39

The stop button can now implement its logic by cancelling any current task, and it has to further make sure to nil out the state so that the “Start timer” button comes back in the UI: Button { self.timerTask?.cancel() self.timerTask = nil } label: { … }

8:54

And with that we can run the preview and observe some interesting things. First, we can certainly start the timer and see that the “Stop timer” button appears with the progress indicator. And then tapping “Stop timer” flips the button back to “Start timer”.

9:09

However, we purposefully have not yet added secondsElapsed to the view and so we had no visual representation that state was actually updating. And if we look at the logs we see the following: CounterView: @self, @identity, _count, _secondsElapsed, _timerTask changed. CounterView: _timerTask changed. CounterView: _timerTask changed.

9:24

The view only rendered 3 times even though the timer has ticked multiple times. There was one render for the initial state, then another render when the timer was started in order to show the “Stop timer” button, and then another render when the timer was stopped in order to show the “Start timer” button.

9:32

The secondsElapsed state was definitely being mutated, yet somehow its mutation did not cause the body of the view to be recomputed. We can even put a print statement in the timer: while true { try await Task.sleep(for: .seconds(1)) self.secondsElapsed += 1 print("secondsElapsed", self.secondsElapsed) }

9:41

And run the preview again, starting and stopping the timer, and we will see in the logs: CounterView: @self, @identity, _count, _secondsElapsed, _timerTask changed. CounterView: _timerTask changed. secondsElapsed 1 secondsElapsed 2 secondsElapsed 3 CounterView: _timerTask changed. So indeed, the @State property is definitely being mutated, yet its mutations did not lead to a recomputation of the view.

9:47

This is a pretty cool feature of the @State property wrapper, and it has behaved this way since the very beginning of SwiftUI. If you use @State to capture some local, mutable state for a view, but never actually use it in the view, then its mutations do not cause the view to be re-rendered.

10:18

If we start using the secondsElapsed state in the view: Section { Text("Seconds elapsed: \(self.secondsElapsed)") … } header: { Text("Timer") }

10:24

And run the preview again, and let the timer tick 3 times, we will see very different logs: CounterView: @self, @identity, _count, _secondsElapsed, _timerTask changed. CounterView: _timerTask changed. secondsElapsed 1 CounterView: _secondsElapsed changed. secondsElapsed 2 CounterView: _secondsElapsed changed. secondsElapsed 3 CounterView: _secondsElapsed changed. CounterView: _timerTask changed.

10:28

Now each time the state changes it does cause the view to re-render because it knows the view is using the secondsElapsed state.

10:42

So this is pretty cool stuff. Somehow the @State property wrapper has the smarts to know when it is used in the view, and if it is, it can make sure that any mutations to it will cause the view to re-render. Whereas if it is not used, then it knows there is no need to re-render when it changes.

10:56

But let’s see just how smart the @State property wrapper is. What if we conditionally show the secondsElapsed in field in the view at runtime. Will the view elide renders when the data isn’t being displayed, but then start rendering again once the data is put into the view?

11:13

To explore this we will add some new state to the view: @State var isDisplayingSecondsElapsed = true

11:22

And we will check that state before displaying the seconds elapsed: if self.isDisplayingSecondsElapsed { Text("Seconds elapsed: \(self.secondsElapsed)") }

11:28

And finally we will make the state toggle-able from the view: Toggle(isOn: self.$isDisplayingSecondsElapsed) { Text("Display seconds") }

11:36

Now in the preview let’s start the timer and toggle the display on and off. Unfortunately we will see that SwiftUI does not quite have the smarts to pull this off: CounterView: @self, @identity, _count, _secondsElapsed, _isDisplayingSecondsElapsed, _timerTask changed. CounterView: _timerTask changed. CounterView: _secondsElapsed changed. CounterView: _isDisplayingSecondsElapsed changed. CounterView: _secondsElapsed changed. CounterView: _secondsElapsed changed. CounterView: _secondsElapsed changed. CounterView: _timerTask changed.

11:51

Even though the seconds elapsed were not being displayed in the view it did somehow still trigger the view’s body to be re-rendered. I guess once it detects the data is being used in the view it just subscribes to its updates forever, even if the data stops being used later. The problems with @State Brandon

12:33

OK, we have now seen that the @State property wrapper is one tool for getting data to dynamically update in a view, even though views are modeled as structs, which do not have a concept of lifetime or behavior. And we saw that @State has a decent amount of smarts baked in to try to reduce the number of times the view re-renders when state changes. Stephen

12:54

However, the @State property has a very specific use case and is not appropriate to use all the time. It is best to use for state that is completely local to a view and does not need to be influenced from the outside, ever . For example, if you were making a reusable UI component, like a button or slider, and wanted to track some internal state, such as whether or not the user is currently touching the component. That is state that the outside should not be able to influence, and that the outside doesn’t really ever even need to know about. Brandon

13:19

However, not all state is of that kind. A lot of state needs to be handed down to views from the parent so that the parent can observe changes the child makes to the state, and so that the parent can make changes to the state and have the child react accordingly. This is incredibly important for deep-linking, where the parent domain needs to be able to construct a piece of state, hand it off to SwiftUI, and let SwiftUI restore all of the child views, including sheets, drill-downs and more.

13:46

If you hold all of your application’s state using the @State property wrapper, and or even when using the @StateObject property wrapper, then you severely limit your ability to deep link into a particular state of your application. Because each little view manages its own local state, the parent has no way to influence that state, and hence no way to deep-link. Stephen

14:07

And there’s another reason to not simply use @State for all of your views’ state. The more complex behavior your view has, in particular the more in needs to interact with external systems, such as making network requests, managing long-living effects, or using Apple’s frameworks such as CoreLocation, then the more difficult it becomes to encapsulate all of that logic in a view.

14:28

For these reasons, and more, is why the concept of an ObservableObject exists, and so let’s quickly look at what that brings to the table.

14:38

First of all, let’s take a quick look at our counter view as it exists now to see how complex it has gotten. A view’s primary purpose is to construct view hierarchy, which is the thing returned from the body property, but we have now sprinkled in all kinds of complex and nuanced logic through the view.

14:52

The places we have added logic and behavior is precisely in escaping closures, such as in button action closures. Those are the only places one can insert behavior in a SwiftUI view. You cannot perform side effects in any other place in a view.

15:04

For example, it would make absolutely no sense whatsoever to start up a timer directly inside the body of the view: var body: some View { let _ = self.timerTask = Task { while true { try await Task.sleep(for: .seconds(1)) self.secondsElapsed += 1 print("secondsElapsed", self.secondsElapsed) } } … }

15:34

First of all, the body of a view can be invoked many, many times. As users of SwiftUI we are supposed to be completely ignorant of the inner workings of the framework, including when and how this property is called. That means we would be accidentally starting this timer many times when we don’t expect that to happen.

15:48

Further, it is not even valid to mutate @State properties while in the middle of evaluating the body of a view. If you were to run this in the simulator you would get a purple warning letting you know this is completely wrong to do: Modifying state during view update, this will cause undefined behavior.

16:04

You can only mutate @State from escaping closures, such as the button action closures.

16:11

So, by using @State for everything in this view, we have smashed together two complementary yet separate concepts: The behavior of the view and the creation of the view. And really, this view is just getting really hard to read. As more and more behavior is added it’s going to be hard to see what the bones of the view really looks like. The fact that it’s a simple Form with a few sections and a few buttons is going to be completely obscured as the vast majority of lines in the view are dedicated to performing actions and not constructing view hierarchy.

16:44

So, if one cares about separating the behavior from the creation of the view, and one cares about state restoration and deep-linking, and if one cares about other things that we don’t have time to talk about such as testing, then one should look into moving the state and behavior of the view into an external object that is injected into the view.

17:04

We are going to start by approaching this in a very naive way. We want to encapsulate the logic and behavior of this feature in some kind of object, and that object must be a reference type since there are side effects being executed. And so what if we didn’t know about the full zoo of property wrappers that SwiftUI comes with, and we just natively implemented the following class: class CounterModel { var count = 0 var secondsElapsed = 0 private var timerTask: Task<Void, Error>? var isTimerOn: Bool { self.timerTask != nil } func decrementButtonTapped() { self.count -= 1 } func incrementButtonTapped() { self.count += 1 } func startTimerButtonTapped() { self.timerTask?.cancel() self.timerTask = Task { while true { try await Task.sleep(for: .seconds(1)) self.secondsElapsed += 1 print("secondsElapsed", self.secondsElapsed) } } } func stopTimerButtonTapped() { self.timerTask?.cancel() self.timerTask = nil } }

17:24

It’s a simple class that wraps some mutable variables, the same ones we were using over in the view. And then we expose methods that can be called from the view to start executing the logic and behavior of the feature. And we like to name the methods after literally what the user does in the view, such as tapping a button, rather than after what work the model will do inside the method. That frees the methods from doing whatever it wants on the inside, and makes it very clear which methods should be called from which parts of the view.

17:46

And this object definitely must be a class because of the side effects. If we try changing it to a struct we will immediately see what goes wrong: struct CounterModel { … }

17:54

We get a bunch of errors about making mutations, but those could easily be fixed by making the methods mutating . The real problem is when we try to mutate the model after some asynchronous work: Mutable capture of ‘inout’ parameter ‘self’ is not allowed in concurrently-executing code

18:10

We cannot access the mutable self inside the sendable closure of Task , and that is ultimately the downfall of trying to use a struct for this model. But that’s OK, classes are great for modeling domains that have behavior, like this one.

18:22

The next thing we want to remark on is that everything in this class is basically what we have littered throughout our view, just now all coalesced in a single, easy-to-understand place. We have some mutable state for the count and seconds elapsed. Further, we even made the timerTask private because the view shouldn’t care about that specific detail. All the view cares about is whether or not the timer is on, and so we have exposed a computed property for that detail.

18:44

And then we have methods the view can call to perform the various actions when the user does something. The contents of these methods is exactly what we are doing over in the view right now. We could even prevent the view from mutating this object’s state properties outside of these methods by marking the properties as private(set) .

19:07

And this class could even be tested since it has been disentangled from the view. We would just instantiate an instance of the object in a test, invoke a few of the methods to mimic the user doing something, such as starting and stopping the timer, and then assert on how the state inside the object changes.

19:22

Now what does it take to use this model in our view? Well, let’s approach this in the most naive way possible. If I want this model to be provided by the parent domain, then I would hold onto it as a simple let : struct CounterView: View { let model: CounterModel … }

19:34

That means whenever someone creates this view they must pass it a CounterModel , and so it passes the responsibility of holding onto a long lasting model up the chain somewhere. Either the parent domain, or the parent of the parent, on and on.

19:45

Alternatively, we could allow this view to own its model by using the @State property wrapper and instantiating the model right away: struct CounterView: View { @State var model = CounterModel() … }

19:53

This means the parent doesn’t have to pass anything along to create the view, but also it means the parent can’t observe or influence the model inside the child.

20:03

There is even another alternative that sits somewhere between the two approaches we just outlined, that of using let and @State . We can also use @State but not provide a default: struct CounterView: View { @State var model: CounterModel … }

20:13

This allows the outside to provide the initial model to the view, so that the outside has a little bit of control. But after the first time of providing the object the outside cannot influence the object ever again. Even something like this: CounterView(model: CounterModel())

20:28

…which constructs a whole new model every time the parent view re-computes its body. Only the first construction of CounterView model will get the fresh model. Every subsequent construction of CounterView will be provided with a new feature model but it will be immediately discarded and the content view will only hold onto the very first one.

20:49

It’s a bit of strange pattern, but it can be useful as long as you are very aware of its caveats. And there is one other caveat, which is that @State does not memoize its wrapped value. This means that a fresh CounterModel will actually be created each time the parent view re-computes its body. This is in stark contrast with @StateObject which does memoize its wrapped value, but in iOS 17 state object is no longer really needed and will probably even be deprecated sometime in the future.

21:15

So, those are the 3 main styles for holding onto an instance variable in a view. Let’s first try out the let style of holding onto the model to see what happens.

21:22

I am going to copy-and-paste the CounterView to a new file, CounterView_State.swift, and rename the view CounterView_State : struct CounterView_State: View { … }

21:51

This way we don’t lose our work that shows how this view looks when everything is built with @State .

21:55

And then back in the main CounterView we will hold onto the model in the view as a simple let : struct CounterView: View { let model: CounterModel … }

22:05

Which means we can get rid of all the @State since it is all encapsulated in the CounterModel : struct CounterView: View { let model: CounterModel // @State var count = 0 // @State var secondsElapsed = 0 // @State var timerTask: Task<Void, Error>? … }

22:08

And then in the body of the view, rather than accessing @State defined directly on the view, we will go through the model. And instead of performing mutations on state and executing behavior directly in the view, we will invoke methods on the model: struct CounterView: View { let model: CounterModel var body: some View { let _ = Self._printChanges() Form { Section { Text("\(self.model.count)") Button("Decrement") { self.model.decrementButtonTapped() } Button("Increment") { self.model.incrementButtonTapped() } } header: { Text("Counter") } Section { Text( """ Seconds elapsed: \(self.model.secondsElapsed) """ ) if !self.model.isTimerOn { Button { self.model.startTimerButtonTapped() } label: { Text("Start timer") } } else { Button { self.model.stopTimerButtonTapped() } label: { HStack { Text("Stop timer") Spacer().frame(width: 4) ProgressView().id(UUID()) } } } } header: { Text("Timer") } } } } For now we have dropped the isDisplayingSeconds state to keep things simple, but we will revisit it later.

23:07

The view has now become much simpler! It is now much clearer that this is a form with some sections and buttons. We don’t have to wade through dozens of lines of logic and behavior to see what the bones of the view truly looks like.

23:23

But now we do have a few compiler errors, because anywhere we construct a CounterView we must provide a CounterView . For example, in the preview: #Preview { CounterView(model: CounterModel()) }

23:30

And in the entry point of the app: @main struct ObservationExplorationsApp: App { var body: some Scene { WindowGroup { CounterView(model: CounterModel()) } } }

23:39

So, we approached extracting the state and behavior out of the view and into a proper class in a very naive manner, but I would hope that it somehow just works.

23:49

Well, of course it does not. If we run in the preview and tap any of the buttons we will see that the view does not update at all. The behavior in the model is definitely executing. For example have a print statement by the timer: while true { try await Task.sleep(for: .seconds(1)) self.secondsElapsed += 1 print("secondsElapsed", self.secondsElapsed) }

24:03

And I can start the timer to see some logs printed: CounterView: @self changed. secondsElapsed 1 secondsElapsed 2 secondsElapsed 3 secondsElapsed 4 secondsElapsed 5 secondsElapsed 6

24:11

Yet the view is not re-rendering, and I can’t even stop the timer.

24:17

Now it would be incredible if this did just work . We implemented the feature in the most direct and naive way possible, and it would feel like magic if somehow the view came to life and behaved the way we expect.

24:28

Well, unfortunately it’s a bit too naive and we are seeing the consequences of that. Because the view is only holding onto a simple reference type, it has no way to abstractly peer into the object and see what state changes, nor can it abstractly peer into its body to see what properties the view uses to render its UI.

24:46

To put it short: there is no observation happening whatsoever.

24:49

The exact same thing happens if we use @State instead and provide a default: struct CounterView: View { @State var model = CounterModel() … }

24:57

But even with that the preview works exactly as it did before. It is completely inert even as we tap the various buttons in the UI. And the same would happen if we didn’t provide a default for @State and instead injected an explicit CounterModel into the view. Observing with @ObservedObject

25:11

Why is this happening? We saw just a moment ago that @State worked just fine when we held the count, seconds elapsed, task, etc. directly in the view. Why does it not work when holding on the CounterModel ?

25:23

Well, this is because the only thing @State actually observes is the wholesale changing of the variable via the willSet property callback. So, for value types this means any change to the value. But for reference types, any change inside the object does not trigger a willSet , and so it is invisible to the @State property wrapper. Brandon

25:40

This means that @State does not really work with reference types, at least in the pre-Observation and pre-iOS 17 world.

25:50

So, this is why SwiftUI needed to ship with some additional tools beyond just @State so that we can extract state and logic into classes but still have their internals observed by the view.

26:10

The first tool one uses to observe the internals of a class in a view is the @ObservedObject property wrapper: struct CounterView: View { @ObservedObject var model: CounterModel … }

26:28

It allows the view to observe what is happening inside the class so that mutations to its fields cause the view to re-render, just as it did when we used @State .

26:41

However, this gives us a compiler error: Generic struct ‘ObservedObject’ requires that ‘CounterModel’ conform to ‘ObservableObject’ …which tells us that you can only use the @ObservedObject property wrapper when the class also conforms to the ObservableObject protocol.

26:50

So, let’s do that: class CounterModel: ObservableObject { … }

26:55

That gets everything compiling, so does it magically work now?

27:00

Well, unfortunately no. We can run the preview and see that tapping the various buttons does nothing to the UI.

27:07

Making the class conform to the ObservableObject is only one step to allowing the view to observe changes to its fields. We further need to decorate each field that the view accesses with the @Published property wrapper.

27:24

Clearly the view uses the count and secondsElapsed properties, so let’s annotate those fields: class CounterModel: ObservableObject { @Published var count = 0 @Published var secondsElapsed = 0 … }

27:34

But the view does not use the timerTask property. In fact, it can’t because we’ve marked it private. So, it seems like we don’t need to mark it @Published at all.

27:38

Also, property wrappers cannot be applied to computed properties: @Published var isTimerOn: Bool { … } Property wrapper cannot be applied to a computed property And so I guess we don’t need to apply @Published to isTimerOn either.

27:50

Let’s see if this has fixed our view. If we run in the preview we will see that the increment and decrement buttons do cause the view to re-render and show the updated count. And even starting the timer works. The seconds elapsed text in the form does update each second. However, it was a little glitchy. I don’t know if you noticed that, so let’s run it again.

28:18

When I start the timer, the button doesn’t change to “Stop timer” until the timer ticks for the first time. It should update right away. Further, if I tap the “Stop timer” button we do see that the seconds elapsed stops going up, yet the button didn’t switch back to “Start timer”. What is going on?

28:28

This is happening because although the view does not directly access the timerTask field, it does indirectly access it via the isTimerOn computed property. So, we do actually have to mark the timerTask property as @Published even though it is private and completely hidden from the view: @Published private var timerTask: Task<Void, Error>?

28:48

Now when we run the preview everything seems to work as we expect.

29:02

However, there is one issue that we can’t see unless we run in the simulator. If we run the app in the simulator and start a timer, we immediately see some purple Xcode warnings letting us know that we are mutating state on a background thread: Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

29:35

The fix is easy enough, we just need to add @MainActor to the task we spin up: self.timerTask = Task { @MainActor in … }

29:46

So, that threading issue aside, we can see that properly setting up an observable object can be extremely tricky. If we try to be precise with exactly what state in our models is observed by applying the @Published property wrapper sparingly, then we run the risk of state updating the model that does not cause the view to re-render. That causes subtle bugs and glitchy behavior in our views that can be hard to track down.

30:21

So, that may lead you to believe that it’s best to be safe than sorry, and to annotate all fields in the model with @Published . Always.

30:30

However, that is not correct either. That can lead you to a situation where your view re-renders many times even though the data it is displaying doesn’t change at all. For example, you may have some internal state that the model needs to implement its logic, but that the view doesn’t need. And that state may rapidly change, possibly dozens of times a second. If you naively apply @Published to everything then you run the risk of re-rendering that view dozens of times a second when it is not necessary.

31:01

We can even see this directly in our little demo. Suppose that we didn’t actually need to display the number of seconds elapsed to the user. Maybe it’s just state that we want to record in the model for later use: Section { // Text("Seconds elapsed: \(self.secondsElapsed)") … } header: { Text("Timer") }

31:15

Well, I would hope that when I start the timer and it starts ticking that the view never needs to re-render since technically none of its data changed. Well, sadly that is not the case: CounterView: @self, @identity, _model changed. CounterView: _model changed. secondsElapsed 1 CounterView: _model changed. secondsElapsed 2 CounterView: _model changed. secondsElapsed 3 CounterView: _model changed.

31:30

With each tick of the timer the view re-renders, even though absolutely nothing changed. This is in stark contrast to when we were using @State . If we did not use a piece of @State in the view then we could mutate it as much as we want and it would never cause the view to re-compute. Nested observation

31:58

So, that is not great. So we really do need to be steadfast in our commitment to mark only the bare essentials of fields in our models with @Published , while full-well knowing that there are potential gotchas, such as if a computed property is accessed in the view, then every field accessed in the property must be marked with @Published .

32:21

But, that’s just the cost of moving logic out of the view and into a dedicated observable object. There are a lot of benefits to doing that, such as testability and easy deep-linking, but you do have to keep in mind the gotchas.

32:32

There are other subtleties that you have to deal with observable objects too. There will be times that you are tempted to nest them. Like say you have a very complex object that you want to break down into simpler objects, and then hold all of those objects in a single, composed object.

32:48

Well, that can be handy, but observation will not work like you expect. Let’s take a look.

32:55

For example, suppose we had a tab view at the root of our application that housed this counter feature. In fact, just to make things interesting, let’s say we have 3 tabs and each tab holds a distinct counter feature.

33:05

Further, suppose this root tab feature is going to have a lot of complex behavior on its own, and so we want to go ahead and create a proper observable object, which we will do in a new file AppView.swift: import SwiftUI class AppModel: ObservableObject { }

33:31

And it will hold onto 3 instances of the CounterModel we’ve been working on to represent the 3 tabs: class AppModel: ObservableObject { let tab1 = CounterModel() let tab2 = CounterModel() let tab3 = CounterModel() }

33:41

And then we can create a simple root-level AppView that has a TabView with 3 tabs: struct AppView: View { @ObservedObject var model: AppModel var body: some View { let _ = Self._printChanges() TabView { NavigationStack { CounterView(model: self.model.tab1) .navigationTitle(Text("Counter 1")) } .tabItem { Text("Counter 1") } NavigationStack { CounterView(model: self.model.tab2) .navigationTitle(Text("Counter 2")) } .tabItem { Text("Counter 2") } NavigationStack { CounterView(model: self.model.tab3) .navigationTitle(Text("Counter 3")) } .tabItem { Text("Counter 3") } } } }

33:49

We can run this in a preview: #Preview { AppView(model: AppModel()) }

34:00

…to see that it works, but it’s not too impressive yet. We can clearly see a tab view with 3 tabs, and in each tab there is a fully functioning counter feature that is independent from any of the other tabs.

34:16

The way we can make this a lot more interesting is by using some of the state from a child tab feature in the parent feature. We would hope it just magically works. Like say we add a badge to the first tab that pulls the count from the counter feature: NavigationStack { CounterView(model: self.model.tab1) .navigationTitle(Text("Counter 1")) } .badge(self.model.tab1.count) .tabItem { Text("Counter 1") }

34:35

Seems innocent enough, but unfortunately it is far to naive to work. If we run the preview we will see that we incrementing the count in the first tab does not make a badge appear at all. This is because nothing has been done to explicitly observe the tab.count state in the AppView . Sure that state is observed inside the first tab, and so it will re-render when the state changes, but nothing is causing the root view to re-render.

35:02

And this would happen even if we marked all of the fields in the AppModel with the @Published property wrapper: class AppModel: ObservableObject { @Published var tab1 = CounterModel() @Published var tab2 = CounterModel() @Published var tab3 = CounterModel() }

35:14

This doesn’t change anything because this will only notify the view to re-render if we wholesale replace tab1 , tab2 or tab3 : model.tab1 = …

35:25

But the changes inside tab1 do not trigger view re-renders. This is for the same reason that the @State property wrapper doesn’t work with reference types. If CounterModel were a value type, then a mutating method on the type would trigger the publisher and cause a view re-render. But, as we discussed a moment ago, these models cannot be value types because they encapsulate behavior, such as the timer.

35:46

What we need to do is have the changes inside the CounterModel reverberate back to the AppModel so that the AppView gets a chance to re-render. We can do this in a very ad-hoc manner by getting a hold of a publisher inside the first tab that emits whenever its internal state is about to change: init() { self.tab1.objectWillChange }

36:15

Subscribe to that publisher so that we can tell the AppModel its internal state is about to change: init() { self.tab1.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } }

36:33

And then we have to store that cancellable somewhere: import Combine … private var cancellables: Set<AnyCancellable> = [] init() { self.tab1.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } .store(in: &self.cancellables) }

37:01

And I guess we might want this to be done for all the tabs, though honestly I’m not really sure: init() { self.tab1.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } .store(in: &self.cancellables) self.tab2.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } .store(in: &self.cancellables) self.tab3.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } .store(in: &self.cancellables) }

37:12

Now when we run the preview it works. When we increment and decrement the count the badge on the first tab updates.

37:20

However, this is definitely not the kind of code we want to write, for a few reasons. First of all, it’s a lot of mysterious looking code just to get the root app view to re-render when some state in a child feature changes. And the code is really repetitive too.

37:34

Now some people in the community have found ways to abstract away this pattern into a handy little property wrapper, sometimes called @Republished . There are some open source projects and gists out there for this, but the long store short is that you can trade all this messy code for something short and succinct: class AppModel: ObservableObject { @Republished var tab1 = CounterModel() @Republished var tab2 = CounterModel() @Republished var tab3 = CounterModel() }

37:59

So that certainly is a lot nicer, but even with this property wrapper we still don’t think it’s a good idea to approach the problem in this way.

38:06

This makes it far too easy for us to observe way too much state in our view. We are now observing every little piece of state inside each tab, even though the only thing we actually care about is the count in the first tab.

38:18

To see this, let’s undo to go back to our ad-hoc version of the @Republished property wrapper. In this version of the code the view behaves how we want visually, but let’s look at the logs. If I switch to the 2nd tab and increment a few times, we will see the following: AppView: @self, @identity, _model changed. CounterView: @self, @identity, _model changed. CounterView: @self, @identity, _model changed. AppView: _model changed. CounterView: _model changed. AppView: _model changed. CounterView: _model changed. AppView: _model changed. CounterView: _model changed. The AppView re-computed its body 4 times even though nothing change in that view at all. No badge needed to appear since that is only for the first tab. Now the problem is of course that we are observing everything inside tab2 and replaying it back to the app model: `swift self.tab2.objectWillChange.sink { [weak self] in self?.objectWillChange.send() } .store(in: &self.cancellables) @T(00:38:48) We did this optimistically because maybe someday in the future we do need some state from tab2 in the app view. But now we see this is a pretty big hammer we are swinging for something that requires a lot more precision. @T(00:39:01) Further, over in the first tab we are also observing too much state. If I clear the logs, switch to tab 1, and increment a few times, we will see something quite reasonable: AppView: _model changed. CounterView: _model changed. AppView: _model changed. CounterView: _model changed. @T(00:39:14) However, if I clear the logs, start the timer, wait a few seconds, and then stop the timer, we will see something quite unfortunate: AppView: _model changed. CounterView: _model changed. secondsElapsed 4 AppView: _model changed. CounterView: _model changed. AppView: _model changed. CounterView: _model changed. secondsElapsed 5 AppView: _model changed. secondsElapsed 6 CounterView: _model changed. AppView: _model changed. CounterView: _model changed. @T(00:39:19) The AppView re-computed its body every time the timer ticked even though it doesn’t care about the secondsElapsed state at all. This is because we are subscribing to all state changes in tab1 when I guess we should only be subscribing to the changes to count . So let’s do that: self.tab1.$count.sink { [weak self] _ in self?.objectWillChange.send() } .store(in: &self.cancellables) @T(00:39:49) Now this operates how I would hope. Incrementing and decrementing in the first tab does cause the badge to update, but starting and stopping the timer does not cause the root app view to re-render at all. @T(00:40:04) So, that seems great, but this is starting to get really hairy. Not only do we have to be precise with our application of the @Published property wrapper to make sure each individual feature observes only the state it cares about, but we also need to be very precise in subscribing to child state changes that should cause the parent feature to re-render. @T(00:40:22) In practice this is incredibly difficult to do perfectly, and so you are more likely to just throw you hands up in the air and decide to subscribe to all state changes of the child feature even though you know it is not efficient to do so. And at the end of the day, SwiftUI simply does not have a good answer for this kind of problem when nesting observable objects. And this goes for holding onto observable objects directly in another observable object, as we have done with the AppModel , but also when holding onto other observable objects in an optional, or in an array, or a dictionary, or any other kind of data structure. Next time: The @Observable present @T(00:40:55, Brandon) So, that was a quick tour of the past observation tools that SwiftUI gave us prior to iOS 17 and Swift 5.9. They were powerful, but they were also very easy to use incorrectly, either leading you to observe too much state causing inefficient views, or observe too little state causing glitchy views. @T(00:41:15) And these are exactly the problems that led to the creation of the new Observation framework in Swift, which is available in Swift 5.9 and iOS 17. It is a pure Swift framework, having nothing to do with any of Apple’s proprietary platforms, but of course its #1 use case is SwiftUI. @T(00:41:32) It allows one to implement features in the naive way that we started with, and it will “just work”. You get to use plain, mostly unadorned reference types and do not have to explicitly mark the fields that need to be observed. You get to hold onto the model in your view as a simple let or @State property. And somehow, magically, the view will properly observe the changes inside the class. And it even works fantastically with nested classes. @T(00:41:58) It’s pretty incredible to see, so let’s check it out…next time! Downloads Sample code 0252-observation-pt1 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 .