Video #266: Observable Architecture: The Point
Episode: Video #266 Date: Jan 29, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep266-observable-architecture-the-point

Description
So what’s the point of observation in the Composable Architecture? While we have seemingly simplified nearly every inch of the library as it interfaces with SwiftUI, let’s zoom out a bit, explore how some integration tests that benchmark certain aspects of the library have changed, and migrate the Todos application we built in the very first tour of this library to the new tools.
Video
Cloudflare Stream video ID: 68a0f9aad58c32dc70b8101b4aebcb86 Local file: video_266_observable-architecture-the-point.mp4 *(download with --video 266)*
Transcript
— 0:05
So, this is pretty incredible. We have now modernized pretty much ever facet of the Composable Architecture by deeply integrating Observation into the library. We’ve made views more efficient, we’ve removed boilerplate from features built in the library, and we’ve removed many concepts that were previously necessary to implement features but no longer are. Stephen
— 0:17
With each change we made to the library we made it a point to demonstrate that views observed the minimal amount of state possible even though we were using simpler, more implicit tools. But now we want to see this even more concretely.
— 0:30
We have an entire integration test suite that is dedicated to running a Composable Architecture feature in the simulator, tapping around on various things, and asserting exactly how many stores are created and destroyed, how many times the scope operation on stores is called, and how many times view bodies are re-computed.
— 0:48
It turns out that all of the changes made so far greatly reduce the amount of the work the library needs to do its job. Let’s take a look at the test suite, and see how things improved. Measuring observation improvements
— 1:00
Let’s take a look at a simple test case we have. We can hop to BasicsTests.swift to see the integration test that exercises the very basic counter feature. This test exercises the behavior of tapping on the “Increment” button: self.app.buttons["Increment"].tap()
— 1:18
And then it asserts on the logs produced when that action takes place: self.assertLogs { """ BasicsView.body StoreOf<BasicsView.Feature>.scope ViewStore<BasicsView.Feature.State, BasicsView.Feature.Action>.deinit ViewStore<BasicsView.Feature.State, BasicsView.Feature.Action>.init WithViewStoreOf<BasicsView.Feature>.body """ }
— 1:22
And there’s a couple of interesting things about this.
— 1:24
First, these logs are the ones that we saw produced in the console when we ran previews and in the simulator. Many parts of the Composable Architecture are instrumented with a logger. For example, when a Store is created: public convenience init<R: Reducer>( initialState: @autoclosure () -> R.State, @ReducerBuilder<State, Action> reducer: () -> R, withDependencies prepareDependencies: ((inout DependencyValues) -> Void)? = nil ) where R.State == State, R.Action == Action { defer { Logger.shared.log("\(typeName(of: self)).init") } … }
— 1:47
Or when a store is deinit ’d: deinit { self.invalidate() Logger.shared.log("\(typeName(of: self)).deinit") }
— 1:52
As well as when a scoping operation causes a piece of parent state to be played back to a child store: childStore.parentCancellable = self.stateSubject .dropFirst() .sink { [weak self, weak childStore] state in … Logger.shared.log("\(typeName(of: self)).scope") }
— 2:00
These are significant events, and the more we can minimize these events the better the performance will be in our apps. And we have also instrumented each integration test case with the logger so that we can see how many times the bodies of those views are computed.
— 2:13
So, when we run the integration test app, these logs are being produced, and we’d like to make assertions on precisely what logs were produced. That allows us to have insight into how many times these events are happening, and will give us insight into if suddenly a feature starts doing more work than we expect.
— 2:30
And so the way we assert against these logs is using a helper we developed called assertLogs . You can provide a string of logs that you expect, and when the test is run it will gather the logs actually produced, and if they don’t match the expectation you will get a failure with a nicely formatted message.
— 2:45
And amazingly, this tool is actually built on the back of our snapshot testing library . I know it seems wild, but it’s true. A few months ago we released an inline snapshot testing tool that allowed you to snapshot test textual formats, and it would record the results of the test directly into the test file.
— 3:00
We’ve even been taking advantage of inline snapshot tool throughout this series whenever we wrote tests for our @ObservableState macro.
— 3:10
We will take advantage of this quite a bit in a moment, but for now let’s just run the test since we have made some quite interesting changes to the library to support observation.
— 3:18
And already we are getting test failures because way fewer things are being logged thanks to the observable machinery: failed - Snapshot did not match. Difference: … BasicsView.body − StoreOf<BasicsView.Feature>.scope − ViewStore<BasicsView.Feature.State, BasicsView.Feature.Action>.deinit − ViewStore<BasicsView.Feature.State, BasicsView.Feature.Action>.init − WithViewStoreOf<BasicsView.Feature>.body
— 3:31
It looks like with the new observation tools we were able to prevent a scoping operation, the creation (and destruction) of a view store, and the execution of a WithViewStore body. And so even for such a simple view we are already seeing something quite dramatic.
— 3:54
To get the correct logs in this test I can do what of two things. I can either set isRecording to true up in the setUp : SnapshotTesting.isRecording = true
— 4:06
If we run the test now we will get the fresh logs recorded to this test file. func testBasics() { self.app.buttons["Increment"].tap() self.assertLogs { """ BasicsView.body """ } self.app.buttons["Decrement"].tap() self.assertLogs { """ BasicsView.body """ } }
— 4:21
And so we now see clear as day that with each action we take in the counter view we are only incurring the cost of a single body computation. No more creation or destruction of view stores, no more WithViewStore bodies, and no more scope computations. That has all gone away all thanks to the fact that we just don’t need the concept of a view store anymore.
— 4:39
So, this assertLogs tool is pretty amazing, and it’s cool to see how tool that was born out of a seemingly unrelated testing tool, that of snapshotting views into images. We massively generalized the notion of snapshot testing to snapshotting any data type into any format, and now we are applying it to test the efficiency of a state-management library such as the Composable Architecture. We really think there are a ton more use cases for snapshot testing in people’s code bases out there, and we highly encourage everyone to experiment with this tool.
— 5:06
It’s also pretty amazing to see that with such a simple demo we are already seeing improvements. But let’s look at something more complicated. Let’s hop over to EnumTests.swift to see the integration tests for the enum feature.
— 5:14
Remember this is the feature that allows you to present and dismiss two different counter features, each held in a piece of optional enum state. The first, most basic, test toggles on the first counter: self.app.buttons["Toggle feature 1 on"].tap()
— 5:35
…and then taps the increment button in that counter: self.app.buttons["Increment"].tap()
— 5:42
And let’s just say we aren’t exactly proud of these logs. The mere act of toggling on the first counter produces the following logs: self.assertLogs { """ BasicsView.body EnumView.body PresentationStoreOf<EnumView.Feature.Destination>.scope StoreOf<BasicsView.Feature>.init StoreOf<BasicsView.Feature>.init StoreOf<BasicsView.Feature?>.init StoreOf<BasicsView.Feature?>.init StoreOf<BasicsView.Feature?>.init StoreOf<EnumView.Feature.Destination>.init StoreOf<EnumView.Feature.Destination>.init StoreOf<EnumView.Feature.Destination?>.scope StoreOf<EnumView.Feature.Destination?>.scope StoreOf<EnumView.Feature>.scope StoreOf<EnumView.Feature>.scope ViewStore<BasicsView.Feature.State, BasicsView.Feature.Action>.deinit ViewStore<BasicsView.Feature.State, BasicsView.Feature.Action>.init ViewStore<BasicsView.Feature.State?, BasicsView.Feature.Action>.deinit ViewStore<BasicsView.Feature.State?, BasicsView.Feature.Action>.init ViewStore<EnumView.Feature.Destination.State, EnumView.Feature.Destination.Action>.deinit ViewStore<EnumView.Feature.Destination.State, EnumView.Feature.Destination.Action>.init ViewStore<EnumView.Feature.Destination.State?, EnumView.Feature.Destination.Action>.deinit ViewStore<EnumView.Feature.Destination.State?, EnumView.Feature.Destination.Action>.init ViewStore<EnumView.ViewState, EnumView.Feature.Action>.deinit ViewStore<EnumView.ViewState, EnumView.Feature.Action>.init ViewStoreOf<BasicsView.Feature>.init ViewStoreOf<BasicsView.Feature?>.init ViewStoreOf<EnumView.Feature.Destination>.init WithViewStore<EnumView.ViewState, EnumView.Feature.Action>.body WithViewStoreOf<BasicsView.Feature>.body WithViewStoreOf<BasicsView.Feature?>.body WithViewStoreOf<EnumView.Feature.Destination>.body WithViewStoreOf<EnumView.Feature.Destination?>.body """ }
— 5:53
Somehow multiple stores and view stores are created, and multiple scope operations are being executed.
— 6:05
It seems like a lot, but unfortunately it is actually true. The problem is that a lot of the seemingly innocent operations, such as the IfLetStore , SwitchStore and ForEachStore , that were previously used incurred a lot of scoping operations under the hood.
— 6:19
For example, previously we used IfLetStore like this: IfLetStore( self.store.scope( state: \.$destination, action: \.destination ) ) { store in … }
— 6:38
So that’s a scoping operation right there.
— 6:41
But then further this initializer of IfLetStore being used has a scope hidden underneath: public init<IfContent>( _ store: Store< PresentationState<State>, PresentationAction<Action> >, @ViewBuilder then ifContent: @escaping (_ store: Store<State, Action>) -> IfContent ) where Content == IfContent? { self.init( store.scope( state: { $0.wrappedValue }, action: PresentationAction.presented ), then: ifContent ) }
— 6:53
But even worse, this is just calling out to another initializer, and it also invokes scope : public init<IfContent>( _ store: Store<State?, Action>, @ViewBuilder then ifContent: @escaping (_ store: Store<State, Action>) -> IfContent ) where Content == IfContent? { let store = store.scope( state: { $0 }, id: nil, action: { $1 }, isInvalid: { $0 == nil }, removeDuplicates: nil ) … }
— 7:00
And in the content closure it invokes scope again : self.content = { viewStore in if var state = viewStore.state { return ifContent( store.scope( state: { state = $0 ?? state return state }, action: { $0 } ) ) } else { return nil } }
— 7:05
And that’s still not the end of it. If we look at the body of the view, where the content closure is invoked, we will see that a WithViewStore is constructed: public var body: some View { WithViewStore( self.store, observe: { $0 }, removeDuplicates: { ($0 != nil) == ($1 != nil) }, content: self.content ) }
— 7:15
And even that initializer uses scope: public init<State>( _ store: Store<State, ViewAction>, observe toViewState: @escaping (_ state: State) -> ViewState, removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool, @ViewBuilder content: @escaping (ViewStore<ViewState, ViewAction>) -> Content, file: StaticString = #fileID, line: UInt = #line ) { self.init( store: store.scope(state: toViewState, action: { $0 }), removeDuplicates: isDuplicate, content: content, file: file, line: line ) }
— 7:19
And in the initializer it calls to, it creates a view store, where a ton of work is being done.
— 7:44
So, once you start traveling through all the layers of abstract it starts to become clear why these logs are so awful.
— 7:55
And we’re not proud that it’s this way. We certainly didn’t plan it from the beginning. It’s the type of thing that just slowly got worse and worse as the library got more and more complicated. We’ve tried to improve things when we can and make sure things weren’t getting too out of hand, but it’s a tough problem to tackle.
— 8:10
Well, all of that changed with the Observation framework. Let’s run the first test to see what happens.
— 8:18
Well, we both of our log assertions fail. The first shows that a whole bunch of logs were removed: failed - Snapshot did not match. Difference: … BasicsView.body EnumView.body − PresentationStoreOf<EnumView.Feature.Destination>.scope StoreOf<BasicsView.Feature>.init − StoreOf<BasicsView.Feature>.init − StoreOf<BasicsView.Feature?>.init − StoreOf<BasicsView.Feature?>.init − StoreOf<BasicsView.Feature?>.init StoreOf<EnumView.Feature.Destination>.init − StoreOf<EnumView.Feature.Destination>.init − StoreOf<EnumView.Feature.Destination?>.scope − StoreOf<EnumView.Feature.Destination?>.scope − StoreOf<EnumView.Feature>.scope − StoreOf<EnumView.Feature>.scope − ViewStore<BasicsView.Feature.State, BasicsView.Feature.Action>.deinit − ViewStore<BasicsView.Feature.State, BasicsView.Feature.Action>.init − ViewStore<BasicsView.Feature.State?, BasicsView.Feature.Action>.deinit − ViewStore<BasicsView.Feature.State?, BasicsView.Feature.Action>.init − ViewStore<EnumView.Feature.Destination.State, EnumView.Feature.Destination.Action>.deinit − ViewStore<EnumView.Feature.Destination.State, EnumView.Feature.Destination.Action>.init − ViewStore<EnumView.Feature.Destination.State?, EnumView.Feature.Destination.Action>.deinit − ViewStore<EnumView.Feature.Destination.State?, EnumView.Feature.Destination.Action>.init − ViewStore<EnumView.ViewState, EnumView.Feature.Action>.deinit − ViewStore<EnumView.ViewState, EnumView.Feature.Action>.init − ViewStoreOf<BasicsView.Feature>.init − ViewStoreOf<BasicsView.Feature?>.init − ViewStoreOf<EnumView.Feature.Destination>.init − WithViewStore<EnumView.ViewState, EnumView.Feature.Action>.body − WithViewStoreOf<BasicsView.Feature>.body − WithViewStoreOf<BasicsView.Feature?>.body − WithViewStoreOf<EnumView.Feature.Destination>.body − WithViewStoreOf<EnumView.Feature.Destination?>.body It’s hard to believe, but out of the 36 logs that we started with, only 7 remained after converting this feature to use observation. That is an 80% reduction in the logs produced.
— 8:35
And the second failure shows even more logs removed: failed - Snapshot did not match. Difference: … BasicsView.body − StoreOf<BasicsView.Feature>.scope − StoreOf<BasicsView.Feature?>.scope − StoreOf<EnumView.Feature.Destination>.scope − StoreOf<EnumView.Feature.Destination?>.scope − StoreOf<EnumView.Feature>.scope − ViewStore<BasicsView.Feature.State, BasicsView.Feature.Action>.deinit − ViewStore<BasicsView.Feature.State, BasicsView.Feature.Action>.init − WithViewStoreOf<BasicsView.Feature>.body
— 8:49
Now when you increment the counter there is no scoping happening whatsoever and no interaction with a view store. All that happens is the BasicsView body is re-computed. Only one log remains out of 9, a nearly 90% reduction.
— 9:09
This almost seems too good to be true, but it is. The Observation framework has not only massively simplified how applications are built with the Composable Architecture, but it has greatly improved the efficiency of our features.
— 9:19
Let’s put the entire test case in record mode: SnapshotTesting.isRecording = true And run the whole test suite to get the freshest logs.
— 9:28
When the test suite finishes we see something much, much shorter. In fact, the test case was previously 263 lines, and now it is only 100 lines.
— 9:35
We can now take the test case out of record mode: // SnapshotTesting.isRecording = true And run the test suite again to make sure everything passes.
— 9:41
…and it does.
— 10:00
Next let’s check out the IdentifiedListTests.swift file. The first test asserts the logs from just adding a single row to the list: func testBasics() { self.app.buttons["Add"].tap() self.assertLogs { """ BasicsView.body IdentifiedListView.body IdentifiedListView.body.ForEachStore IdentifiedListView.body.ForEachStore IdentifiedStoreOf<BasicsView.Feature>.deinit IdentifiedStoreOf<BasicsView.Feature>.init IdentifiedStoreOf<BasicsView.Feature>.scope Store<UUID, Action> Store<UUID, BasicsView.Feature.Action>.deinit Store<UUID, BasicsView.Feature.Action>.init Store<UUID, BasicsView.Feature.Action>.init Store<UUID, BasicsView.Feature.Action>.init StoreOf<BasicsView.Feature>.init StoreOf<BasicsView.Feature>.init StoreOf<IdentifiedListView.Feature>.scope StoreOf<IdentifiedListView.Feature>.scope ViewIdentifiedStoreOf<BasicsView.Feature>.deinit ViewIdentifiedStoreOf<BasicsView.Feature>.init ViewStore<BasicsView.Feature.State, BasicsView.Feature.Action>.deinit ViewStore<BasicsView.Feature.State, BasicsView.Feature.Action>.init ViewStore<IdentifiedArray<UUID, BasicsView.Feature.State>, IdentifiedAction<UUID, BasicsView.Feature.Action>>.deinit ViewStore<IdentifiedArray<UUID, BasicsView.Feature.State>, IdentifiedAction<UUID, BasicsView.Feature.Action>>.init ViewStore<IdentifiedListView.ViewState, IdentifiedListView.Feature.Action>.deinit ViewStore<IdentifiedListView.ViewState, IdentifiedListView.Feature.Action>.init ViewStore<UUID, BasicsView.Feature.Action>.deinit ViewStore<UUID, BasicsView.Feature.Action>.init ViewStore<UUID, BasicsView.Feature.Action>.init ViewStore<UUID, BasicsView.Feature.Action>.init ViewStoreOf<BasicsView.Feature>.init WithViewIdentifiedStoreOf<BasicsView.Feature>.body WithViewStore<IdentifiedListView.ViewState, IdentifiedListView.Feature.Action>.body WithViewStore<UUID, BasicsView.Feature.Action>.body WithViewStoreOf<BasicsView.Feature>.body """ } }
— 10:10
That is 33 lines of logs for one simple action. If we run this test we will get the following test failure: failed - Snapshot did not match. Difference: … BasicsView.body IdentifiedListView.body IdentifiedListView.body.ForEachStore IdentifiedListView.body.ForEachStore − IdentifiedStoreOf<BasicsView.Feature>.deinit − IdentifiedStoreOf<BasicsView.Feature>.init − IdentifiedStoreOf<BasicsView.Feature>.scope − Store<UUID, Action> − Store<UUID, BasicsView.Feature.Action>.deinit − Store<UUID, BasicsView.Feature.Action>.init − Store<UUID, BasicsView.Feature.Action>.init − Store<UUID, BasicsView.Feature.Action>.init StoreOf<BasicsView.Feature>.init − StoreOf<BasicsView.Feature>.init − StoreOf<IdentifiedListView.Feature>.scope − StoreOf<IdentifiedListView.Feature>.scope − ViewIdentifiedStoreOf<BasicsView.Feature>.deinit − ViewIdentifiedStoreOf<BasicsView.Feature>.init − ViewStore<BasicsView.Feature.State, BasicsView.Feature.Action>.deinit − ViewStore<BasicsView.Feature.State, BasicsView.Feature.Action>.init − ViewStore<IdentifiedArray<UUID, BasicsView.Feature.State>, IdentifiedAction<UUID, BasicsView.Feature.Action>>.deinit − ViewStore<IdentifiedArray<UUID, BasicsView.Feature.State>, IdentifiedAction<UUID, BasicsView.Feature.Action>>.init − ViewStore<IdentifiedListView.ViewState, IdentifiedListView.Feature.Action>.deinit − ViewStore<IdentifiedListView.ViewState, IdentifiedListView.Feature.Action>.init − ViewStore<UUID, BasicsView.Feature.Action>.deinit − ViewStore<UUID, BasicsView.Feature.Action>.init − ViewStore<UUID, BasicsView.Feature.Action>.init − ViewStore<UUID, BasicsView.Feature.Action>.init − ViewStoreOf<BasicsView.Feature>.init − WithViewIdentifiedStoreOf<BasicsView.Feature>.body − WithViewStore<IdentifiedListView.ViewState, IdentifiedListView.Feature.Action>.body − WithViewStore<UUID, BasicsView.Feature.Action>.body − WithViewStoreOf<BasicsView.Feature>.body
— 10:22
Showing that all but 5 logs have been eliminated, an 85% reduction.
— 10:34
Let’s quickly put the test case in record mode: SnapshotTesting.isRecording = true
— 10:44
…and run the full case to get the freshest logs. We can see that a ton of logs have been removed. This file has gone from 134 lines to just 59.
— 11:27
And the reason these logs are being reduced so dramatically is because we have gotten rid of the ForEachStore . Just as with the IfLetStore is has a cascading effect of multiple scopes on the inside, and all of that just vanishes now.
— 11:51
And before moving on let’s update one last test. We also converted the PresentationView integration test case to use the new observable tools. If we hop over to PresentationTests.swift we will see a test file that is 205 lines long, and looks like it has way, way too many logs for the kinds of things we were doing in those views.
— 12:08
Let’s turn on record mode in this file: SnapshotTesting.isRecording = true
— 12:12
And run the test to see that now the test file is only a mere 71 lines, and all of the logs look much, much more reasonable.
— 12:31
The most logs we see for any one action is just 3, such as when presenting a sheet: self.app.buttons["Present sheet"].tap() self.assertLogs { """ BasicsView.body PresentationView.body StoreOf<BasicsView.Feature>.init """ }
— 12:44
And that makes sense because the act of presenting the sheet does cause the parent view to re-compute, as well as the child view being presented, and a store must be created for that child view. Throwback: Todos
— 12:54
This is absolutely incredibly stuff. We are see very concretely now, in our integration test suite, that the amount of work being performed in our views significantly drops when using the new observation tools in the Composable Architecture. Fewer stores are created, fewer scopes are called, views compute their bodies less frequently, and then of course the entire concept of a view store and all of its baggage has completely disappeared. Brandon
— 13:16
And we are getting very close to being at the end of this series of episodes and releasing the final version of the observation tools out in the world, but there’s one last thing we want to show off.
— 13:25
We thought it would be fun to have a little throwback to one of the first moderately complex apps we built in the Composable Architecture. Three and a half years ago we released the first tour episodes of the library, and in that 4-part series we built a little todo application. That was a lot of fun, but also a lot has changed since then.
— 13:45
So we think it would be fun to quickly take that code and update it to use the new observation tools. And in fact, the repo for the library has that exact todo application provided as a demo application.
— 13:57
So, let’s take a quick look at the code, and modernize it!
— 14:01
I’m in the todos demo code right now, and let’s quickly run the preview to remind ourselves of everything it does.
— 14:33
And now let’s start modernizing it. First thing we will do is apply the @ObservableState macro to the Todos.State struct, and drop the @BindingState property wrappers since we don’t need those anymore in the brave new world of observation: @ObservableState struct State: Equatable { var editMode: EditMode = .inactive var filter: Filter = .all var todos: IdentifiedArrayOf<Todo.State> = [] }
— 14:52
Then down in the view we can completely get rid of the concept of a ViewState struct: // struct ViewState: Equatable { // @BindingViewState var editMode: EditMode // @BindingViewState var filter: Filter // let isClearCompletedButtonDisabled: Bool // // init(store: BindingViewStore<Todos.State>) { // self._editMode = store.$editMode // self._filter = store.$filter // self.isClearCompletedButtonDisabled = // !store.todos.contains(where: \.isComplete) // } // }
— 15:15
Next we can completely remove the WithViewStore from the body of the view: var body: some View { // WithViewStore( // self.store, observe: ViewState.init // ) { viewStore in … // } }
— 15:22
And then update all uses of the viewStore to instead just use the store . And we can use @State to get a binding to a store, and use that to derive bindings to the filter and edit mode.
— 15:53
But there is one computation on view state that was handy, and that’s the isClearCompletedButtonDisabled boolean. That lets us know if there are any un-completed todos so we know whether or not to disable the “Clear” button.
— 16:00
This is appropriate to move to a computed property directly on the state: struct State: Equatable { … var isClearCompletedButtonDisabled: Bool { !self.todos.contains(where: \.isComplete) } }
— 16:46
And now we can also update our use of ForEachStore to just be a simple ForEach : ForEach( store.scope(state: \.filteredTodos, action: \.todos) ) { store in TodoView(store: store) }
— 16:56
That’s all it takes, and everything is compiling and the todos app works exactly as it did before.
— 17:09
There’s one more feature in the todos app, and that is the feature for a specific row of the list of todos. Now technically this probably didn’t really need to be its own dedicated Composable Architecture feature. Especially since it doesn’t have any complex logic or side-effects, but we did it this way just to show how it looks to integrate an entire collection of child features into a parent feature.
— 17:35
And modernizing it is quite easy. We can just apply the @ObservableState macro and get rid of the @BindingState property wrapper: @ObservableState struct State: Equatable, Identifiable { var description = "" let id: UUID var isComplete = false }
— 17:42
And then we can update the view to get rid of the WithViewStore and just use the store for everything: struct TodoView: View { @State var store: StoreOf<Todo> var body: some View { HStack { Button { store.isComplete.toggle() } label: { Image( systemName: store.isComplete ? "checkmark.square" : "square" ) } .buttonStyle(.plain) TextField("Untitled Todo", text: $store.description) } .foregroundColor(store.isComplete ? .gray : nil) } }
— 19:26
That’s all it takes, and everything works exactly as it did before, but we have greatly simplified this code.
— 19:32
But now let’s have a little fun with this.
— 19:34
One thing that’s a little interesting about todos is that typically you only have a few that are active at a time, but you may have many that were completed in the past. In fact, let’s emulate this by updating our todos mock to add a bunch of completed todos: … Todo.State( description: "Walk the dog", id: UUID(), isComplete: true ), Todo.State( description: "Get haircut", id: UUID(), isComplete: true ), Todo.State( description: "Edit episode", id: UUID(), isComplete: true ), Todo.State( description: "Respond to open source questions", id: UUID(), isComplete: true ),
— 19:59
When we run this in the preview and switch to the “Completed” tab we will see them all listed here. But over time this list will get longer, and longer, and longer.
— 20:11
And this is why most todo apps, including Apple’s own Reminders app, truncates the list of completed items to some small number, and then gives you the option to reveal all of them. This turns out to be incredibly easy for us to implement now.
— 20:26
Currently our ForEach looks like this: ForEach( store.scope(state: \.filteredTodos, action: \.todos) ) { store in TodoView(store: store) }
— 20:35
And recall that this scope operation is actually give us a collection of stores, which means we can perform various array operations on it. In particular, we can prefix it to get just the first 3: store.scope(state: \.filteredTodos, action: \.todos) .prefix(3)
— 20:57
But we don’t always want just the first 3. We want this to happen if we are on the “Completed” filter: store.scope(state: \.filteredTodos, action: \.todos) .prefix(store.filter == .completed ? 3 : .max)
— 21:23
And just that one small change already makes it so that the “Completed” filter only shows 3 todos even though we have more than 3.
— 21:33
But, we also want the option to expand the list and see all the completed todos. So, we need a button: Section { Button("See all \(store.filteredTodos.count) completed") { } }
— 21:57
But we only want to show this button when the “Completed” filter is active and when there are more than 3 todos: if store.filter == .completed, store.filteredTodos.count > 3 { Section { Button( "See all \(store.filteredTodos.count) completed" ) { } } }
— 22:26
And we can send an action when the button is tapped: store.send(.seeAllCompletedButtonTapped)
— 22:34
We’ll add that action to the feature’s Action enum: case seeAllCompletedButtonTapped
— 22:39
And handle it in the reducer: case .seeAllCompletedButtonTapped: return .none
— 22:42
But what do we want to do in this action?
— 22:45
We need to keep track of an additional piece of boolean state that lets us know whether or not we are expanding all of the completed todos: var isExpandingAllCompletedTodos = false
— 23:05
And then toggle that state on in the action: case .seeAllCompletedButtonTapped: state.isExpandingAllCompletedTodos = true return .none
— 23:10
And now we can beef up our prefix to take into account that state: store.scope(state: \.filteredTodos, action: \.todos) .prefix( store.filter == .completed && !store.isExpandingAllCompletedTodos ? 3 : .max )
— 23:46
And beef up the conditional that determines when to show the button: if !store.isExpandingAllCompletedTodos, store.filter == .completed, store.filteredTodos.count > 3 { Section { Button( "See all \(store.filteredTodos.count) completed" ) { store.send(.seeAllCompletedButtonTapped) } } }
— 23:54
That’s all it takes, and we can run the preview to see that everything works as we expect.
— 24:06
Even cooler, we can make it so that if you ever change the filter we will reset the isExpandingAllCompletedTodos state back to false so that next time you go back to the “Completed” filter it has re-collapsed the todos: BindingReducer() .onChange(of: \.filter) { _, _ in Reduce { state, _ in state.isExpandingAllCompletedTodos = false return .none } }
— 24:55
And we can run that in the preview to see that everything works as we expect. Outro
— 25:23
Well, we are finally done with this series of episodes where we have introduced observation to the Composable Architecture. We have seen a lot along the way.
— 25:33
We’ve seen that first and foremost it is possible to leverage Swift 5.9’s observation tools in the Composable Architecture even though the library is largely built on value types, and the observation tools don’t play nicely with value types by default. Stephen
— 25:51
Then we saw that by having a lightweight way to automatically observe state in the view we could cut tool after tool from the library. The specialized views such as IfLetStore , SwitchStore and ForEachStore are all gone. As are all the various navigation APIs for sheets, popovers, fullscreen covers, and more. We can now deprecate all of those tools, and in the future delete thousands of lines of code and documentation. Brandon
— 26:14
Then we saw that we can massively simplify how bindings are handled in the Composable Architecture. We are now able to write our views in a style that looks much more similar to vanilla SwiftUI. Stephen
— 26:24
And finally we saw very concretely how all of these changes improved the efficiency of the library. The number of stores created, the number of scopes computed, and the number of view bodies re-computed has been massively cut.
— 26:38
So this is all great, but there’s even more that we don’t have time to discuss in episodes. For one thing, in the final version of these tools we are releasing we were able to make all of these changes in a 100% backwards compatible way. That means this release is just a minor point release. You should be able to update your dependency on the Composable Architecture immediately without making a single change to your application. Brandon
— 27:00
But even better, the tools were back-ported all the way back to iOS 13. We didn’t show how we accomplished this in the episodes, but you can check it out in the repo if you are curious. And this means you can use @ObservableState starting today even if you can’t target iOS 17 right now. Stephen
— 27:19
It’s all pretty incredible, and we hope everyone enjoys using the new tools. But that’s it for this series.
— 27:25
Until next time. Downloads Sample code 0266-observable-architecture-pt8 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 .