Video #263: Observable Architecture: Observing Collections
Episode: Video #263 Date: Jan 8, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep263-observable-architecture-observing-collections

Description
We can now observe struct, optional, and enum state in the Composable Architecture, but what about collections? Let’s explore what it takes to get rid of the ForEachStore wrapper view for a vanilla ForEach view instead, while still observing updates to collection state in the most minimal way possible.
Video
Cloudflare Stream video ID: 945794a4d553579b22c53160e246dce1 Local file: video_263_observable-architecture-observing-collections.mp4 *(download with --video 263)*
Transcript
— 0:05
Thanks to the new Observation tools in Swift 5.9 we have now gotten rid of 4 specialized view helpers that previously existed only to aid in minimizing view re-computation. They are WithViewStore , IfLetStore , SwitchStore and CaseLet . We now get to use simpler, more vanilla Swift code to constructs these views, and they still minimally observe only the state that is touched in the view. Stephen
— 0:30
But there’s another really powerful and popular view helper that ships with the library that allows you to decompose complex list-based features into smaller units, and that’s the ForEachStore . With it you can easily transform a store of some collection of features into individual stores for each element in the collection. This allows you to have a dedicated, isolated Composable Architecture feature for each row in a list.
— 0:52
It would be amazing if we could get rid of this concept too, and just use a vanilla SwiftUI ForEach view. Well, this is absolutely possible, and it greatly simplifies the way one deals with lists in the Composable Architecture.
— 1:04
Let’s take a look. Getting rid of ForEachStore
— 1:07
In order to explore this we are going to look at yet another integration test demo. There’s a view called IdentifiedListView , and it demonstrates a few interesting things about lists. Let’s run the preview to take a look.
— 1:22
We can add a few rows to this list, and we will see that there is an independent, isolated version of the BasicsView.Feature running in each row. We can increment and decrement the count in each row of the list, and we can remove a row from the list.
— 1:37
Also, just to really drive home the idea of minimally observing the state, we throw in an interesting subtlety. We have the parent view observe the count of the first row so that we can show that the parent view should never re-render unless that count is changed.
— 2:19
And we can see that this does indeed work. We can open the console to see that adding and removing rows causes the ForEach closure to be called and a single BasicsView body is called, but the parent IdentifiedListView is not re-computed. If we increment or decrement in the first row, we will see the IdentifiedListView does re-compute its body, but that is expected because it is observing that state.
— 2:38
But incrementing or decrementing within any other row only causes that one view to re-compute. Nothing else does.
— 2:48
So, the view does behave quite well when it comes to minimal view re-computations, but the code to achieve this can be improved quite a bit. So, let’s re-imagine how we do lists in the library. We’ll just chisel away at this view to get it down to how we wish we could implement things, and then we will make it a reality.
— 3:04
First of all we have a dedicated ViewState type in order to hold the minimum amount of observed state: struct ViewState: Equatable { var firstCount: Int? init(state: Feature.State) { self.firstCount = state.rows.first?.count } }
— 3:16
Well we of course do not want to have to maintain things like this, so let’s delete it.
— 3:21
And lower down we have a WithViewStore : WithViewStore( store, observe: ViewState.init ) { viewStore in … }
— 3:27
Which we do not want either. We would far prefer to reach directly into the store to access state.
— 3:33
Then we immediately get a compiler error where we previously were going through the viewStore to get state: if let firstCount = viewStore.firstCount { … }
— 3:38
But now let’s do the far simpler thing: reach through the store , into the rows , and grab the first count: if let firstCount = store.rows.first?.count { … }
— 3:51
This of course doesn’t compile because our state is not observable: Referencing subscript ‘subscript(dynamicMember:)’ on ‘Store’ requires that ‘IdentifiedListView.Feature.State’ conform to ‘ObservableState’
— 3:57
But we will be able to fix that soon.
— 3:59
Next we use the ForEachStore : ForEachStore( store.scope(state: \.rows, action: \.row) ) { store in … }
— 4:04
This works by providing a store that is scoped down to a domain that deals with IdentifiedArray s: var rows: IdentifiedArrayOf<BasicsView.Feature.State> = []
— 4:09
…and identified actions: case rows(IdentifiedActionOf<BasicsView.Feature>)
— 4:11
Then, the ForEachStore can split off a dedicated store that is focused on just a single element of the collection, and that is the argument handed to the trailing closure.
— 4:19
There are two main reasons that ForEachStore exists. One is to hide some messy details of indexing into the collection and bundling child actions up into identified actions so that you can construct those child stores.
— 4:32
But another is to make it so that the view re-computes its body in the minimal way possible, which is when elements are added, removed or when an element’s ID in the collection changes. We can even see this by hopping over to the ForEachStore.swift file to see this removeDuplicates logic: removeDuplicates: { areOrderedSetsDuplicates($0.ids, $1.ids) }
— 4:56
Since IdentifiedArray uses OrderedSet under the hood for its IDs, we get a fast path to detect if two sets of IDs are the same. This is an extremely efficient operation.
— 5:06
And we really did need a dedicated view to accomplish this because technically the ForEachStore is not just a ForEach with some additional logic on the inside. Instead, it’s a WithViewStore that wraps a ForEach : self.content = WithViewStore( store, observe: { $0 }, removeDuplicates: { areOrderedSetsDuplicates($0.ids, $1.ids) } ) { viewStore in ForEach( viewStore.state, id: viewStore.state.id ) { element in … } }
— 5:19
And so there is no way to accomplish this with just a new ForEach initializer. We really did need a dedicated view for this.
— 5:29
So, the ForEachStore certainly has a purpose, but we would hope we could get those benefits, that of hiding complexity and improving performing, without needing a dedicated view type. Would it be much better if we could just do this: ForEach( store.scope(state: \.rows, action: \.row) ) { store in … }
— 5:50
We are using the regular ForEach view, and even its regular initializer, but we are passing a store scoped down to some collection domain, and it should just work .
— 5:56
And if we accomplish this, there is even more that can be cleaned up in this view. Inside the ForEachStore trailing closure is this mess: let idStore = store.scope(state: \.id, action: \.self) WithViewStore(idStore, observe: { $0 }) { viewStore in let _ = Logger.shared.log("\(type(of: idStore))") Section { HStack { VStack { BasicsView(store: store) } Spacer() Button { store.send( .removeButtonTapped(id: viewStore.state) ) } label: { Image(systemName: "trash") } } } .buttonStyle(.borderless) }
— 6:01
We are scoping the child element store down to just the ID of the element just so that we can immediately hit it with WithViewStore , and that is done just so that we can pluck out the ID of the element in order to send the delete action.
— 6:10
This is truly awful code. There is a ton of indirection here just to do something as simple as wanting to grab the ID from some state. And so what if we could replace all of this code with something as simple as this: let _ = Logger.shared.log("\(Self.self).body.ForEachStore") Section { HStack { VStack { BasicsView(store: store) } Spacer() Button { store.send( .removeButtonTapped(id: store.state.id) ) } label: { Image(systemName: "trash") } } } .buttonStyle(.borderless)
— 6:32
No additional store scoping. No additional WithViewStore . And that would be pretty amazing. Array scope
— 6:41
OK, we have yet again written some theoretical, not compiling code that we aspire to be able to write. It would be great if we could make this work because it would mean using the more familiar SwiftUI ForEach view, which would make this code look a lot more similar to plain, vanilla SwiftUI. Brandon
— 6:56
Yeah, that would be great, and it is 100% possible, so let’s start getting our hands dirty.
— 7:02
First things first, let’s modernize the reducer to use @ObservableState : @Reducer struct Feature { @ObservableState struct State: Equatable { var rows: IdentifiedArrayOf<BasicsView.Feature.State> = [] } … }
— 7:14
That already fixes the first compiler error we had since it is not perfectly fine to reach through the store : if let firstCount = self.store.rows.first?.count { … }
— 7:30
The next compiler error we have is our usage of ForEach : Generic struct ‘ForEach’ requires that ‘Store<IdentifiedArrayOf<BasicsView.Feature.State>, _>’ (aka ‘Store<IdentifiedArray<UUID, BasicsView.Feature.State>, _>’) conform to ‘RandomAccessCollection’
— 7:32
This of course does not work. The ForEach initializer requires that the data you pass to it be a RandomAccessCollection . We can even hop over to the docs to confirm that: Note Available when Data conforms to RandomAccessCollection, ID is Data.Element.ID, Content conforms to View, and Data.Element conforms to Identifiable.
— 7:51
So, it seems that the only way for this to work is if Store somehow became a RandomAccessCollection . That would be really interesting if possible, but also sounds a little dangerous. The Store is a reference type, whereas collections are typically value types. And the ForEach view in SwiftUI probably heavily leverages the fact that collections are typically value types in order for it to perform simple diffing of collections.
— 8:34
So, we aren’t going to make Store into a collection, but we can add an overload of the scoping operation on Store so that it returns a collection type that can be handed to ForEach directly: ForEach( store.scope(state: \.rows, action: \.row) ) { store in … }
— 8:46
Let’s try that out.
— 8:47
Let’s hop back over to Store+Observation.swift, and add a new scope method to the Store type: extension Store { public func scope() { } }
— 9:02
And we are going to want to limit this to stores that hold onto ObservableState so that we can make sure that the view observes changes to the state, just as we did with the optional scope earlier: extension Store where State: ObservableState { public func scope() { } }
— 9:28
It is going to take a key path for isolating an IdentifiedArray in the store’s domain, as well as a case key path for isolating an identified action in the store’s domain: extension Store where State: ObservableState { public func scope<ElementID, ElementState, ElementAction>( state: KeyPath< State, IdentifiedArray<ElementID, ElementState> >, action: CaseKeyPath< Action, IdentifiedAction<ElementID, ElementAction> > ) }
— 11:00
And then rather than returning a single Store that is focused on the collection domain, we will instead return an entire collection of stores, where each store is focused on a single element of the collection. And we can even just use a plain array: extension Store where State: ObservableState { public func scope<ElementID, ElementState, ElementAction>( state: KeyPath< State, IdentifiedArray<ElementID, ElementState> >, action: CaseKeyPath< Action, IdentifiedAction<ElementID, ElementAction> > ) -> [Store<ElementState, ElementAction>] { } }
— 11:46
Then inside here, since state is observable, we can reach right into the store’s state and further key path in to get the identified array sitting inside: self.state[keyPath: state]
— 12:04
And then we can map on this array to get each element: self.state[keyPath: state].map { element in }
— 12:10
But we don’t actually need access to the full element. In fact, the element is completely generic and so we don’t even know anything about it. All we need is its ID, so let’s map on the ordered set of IDs instead: self.state[keyPath: state].ids.map { id in }
— 12:30
Now we need to return a store from map , but each store will be derived from self , and so we want to use scope: self.state[keyPath: state].ids.map { id in self.scope }
— 12:42
And we are going to have to use the internal, more powerful version of scope that we previously used in order to implement a scoping operation that works with if let : self.scope( state: <#(State) -> ChildState#>, id: <#(State) -> AnyHashable#>, action: <#(State, ChildAction) -> Action#>, isInvalid: <#(State) -> Bool#>, removeDuplicates: <#(ChildState, ChildState) -> Bool#> ) This has more arguments than the regular public scopes you would use in a typical Composable Architecture application, and we are going to need this extra power.
— 13:01
Let’s start filling in the arguments one-by-one. First we need a way to transform the store’s state into just a single piece of child state. We can do this by using the key path to go through the state to the identified array, and then further subscript into the identified array with the ID: state: { $0[keyPath: state][id: id] },
— 13:34
But that ID subscript is optional since you may be requesting an element that does not exist, and for now we will force unwrap: state: { $0[keyPath: state][id: id]! },
— 13:54
This force unwrap is dangerous, and it can actually lead to a crash because SwiftUI can often hang onto to bindings long after a view has gone away and then read from and write to those bindings, causing all kinds of problems. But luckily there is a workaround.
— 14:14
Next is the id argument, which is useful for providing child stores with a stable ID so that we can cache the store internally. We previously saw this with the optional scope we developed, in that situation we used the key path as the ID. In this situation we have a better identifier because we have a unique ID from the identified array already: id: { _ in id },
— 14:52
And we can even mix in the state and action key paths to increase the strength of this cache key too: id: { _ in [state, action, id] as [AnyHashable] },
— 15:09
Next we have the action argument, which is responsible for bundling a child action into the parent domain. The closure is provided the child action: action: { elementAction in },
— 15:16
And we have to somehow turn this into an Action : action: { elementAction -> Action in }
— 15:23
We can use the action case key path to turn the ID of the element and the child action into an action of the parent domain: action: { childAction -> Action in action(.element(id: id, action: childAction)) },
— 15:53
Then we have the isInvalid argument. This is used to determine when the Store has technically gone out of scope, but for whatever reason SwiftUI is still holding onto a reference for a bit too long. This can cause a lot of problems because SwiftUI will erroneously write to bindings in views that are no longer on screen, and that causes actions to be sent into the store, wreaking havoc.
— 16:26
We will say that this store becomes invalidated if the ID is no longer contained in the order set of IDs in the identified array: isInvalid: { !$0[keyPath: state].ids.contains(id) },
— 16:57
And finally we have the removeDuplicates argument, which is just for performance tweaking and we do not need right now: removeDuplicates: nil
— 17:06
Now this isn’t yet compiling, and the compilation error is a bit mystifying. Something is wrong with the ForEach : ForEach( self.store.scope(state: \.rows, action: \.rows) ) { store in … } Referencing initializer ‘init(_:content:)’ on ‘ForEach’ requires that ‘Store<BasicsView.Feature.State, BasicsView.Feature.Action>’ conforms to ‘Identifiable’
— 17:20
The problem is that we are trying to use the ForEach on a collection of stores, and the Store type is not identifiable.
— 17:32
But, now that we cache scoped stores there is a stable notion of store in a tree of features. We can now use the store’s object identifier as a stable notion of ID, and that would allow the ForEach to work just fine with a collection of stores.
— 18:06
So why not just make Store into an Identifiable type, where it’s referential identity is the source of identification: extension Store: Identifiable {}
— 18:31
Now we can just do this: ForEach( store.scope(state: \.rows, action: \.rows) ) { store in … } …and now everything in this file is compiling.
— 18:38
And because the child store derived from the main store and handed to this closure encloses an observable domain, we are even allowed to reach directly into the store and grab state like this if we want: Text(store.count.description) So that’s pretty cool. But let’s not do that now.
— 19:10
We can run the preview and see that it seems to work exactly as it did before. We can add rows, increment and decrement within each row, and even changing the count in the first row causes the parent display of that count to change. We can also remove rows. Minimal observation of collections
— 19:35
This is looking pretty great! We introduced a new scoping operation on stores that allow us to derive a collection for stores so that we can just hand that right over to ForEach , and everything seems to work.
— 19:47
And it’s worth taking a moment to reflect on these new scoping operations we have created. It may seem like we are creating a bunch of overloads of scope , and we already had a few scopes defined in the library, and so isn’t that going to be problematic for Swift compile times? Stephen
— 20:01
However, there are only really 3 fundamental scoping operations on stores, and all the other ones are going to be deprecated and removed from the library someday in the future:
— 20:10
First there’s the standard scoping operation that uses a state key path and action case key path for isolating a child domain in a parent domain. This is the original scope that has been in the library basically since day one. Brandon
— 20:23
Then for the observation tools we added another scope that is similar to the previous scope , but it forces you to isolate an optional child domain inside a parent domain. And this operation works perfectly with if let statements. Stephen
— 20:36
And then we just introduced another scope operation that is also similar to the previous two scope s, but it forces you to isolate an identified array of child domains inside a parent domain. And this operation works perfectly with ForEach .
— 20:49
And that’s it. Those 3 scope operations will satisfy all of your needs for chiseling away at a parent store to get down just a child domain and hand it off to a child view.
— 20:59
And once we can delete all the cruft of past scopes that no are no longer needed, compile times should greatly improve. But, unfortunately that is a breaking change and so we cannot just rush into that.
— 21:09
So, things are looking great, but there is a problem. And it’s the same problem we keep seeing over and over again, and have to keep fixing: over observation of state.
— 21:18
Let’s see how this is manifesting in our case study, and see what it takes to fix.
— 21:23
Let’s add a few rows, clear the logs and filter the logs for “view.body”, and increment within one of the rows that is not the first. We will see the following printed to the logs: BasicsView.body IdentifiedListView.body IdentifiedListView.body.ForEachStore IdentifiedListView.body.ForEachStore IdentifiedListView.body.ForEachStore
— 21:47
For some reason the parent view has decided to re-compute its body, and consequently the ForEach had to evaluate its closure a few times too, even though nothing being observed by the parent view actually changed. Only a single row changed, and so hopefully only the body of that view would be re-computed.
— 22:05
To see why this is happening let’s turn on the Observation.ObservationRegistrar.withMutation symbolic breakpoint again, and tap “Increment” again.
— 22:22
The first place we get caught is in the BasicsView.Feature because the count was indeed mutated: var count = 0 { … set { if let lhs = _count as? any ObservableState, let rhs = newValue as? any ObservableState, lhs._$id == rhs._$id { _count = newValue } else { withMutation(keyPath: \.count ) { _count = newValue } } } }
— 22:33
And this is a perfectly fine mutation to report to the registrar. First of all, the change to the count should cause the BasicsView to re-compute its body, and second the count is a simple integer, which is definitely not ObservableState , and so there is no way to elide mutations when the value’s identity hasn’t changed.
— 22:44
Let’s continue execution so we can see where we get caught next: var rows: IdentifiedArrayOf<BasicsView.Feature.State> = [] { … set { if let lhs = _rows as? any ObservableState, let rhs = newValue as? any ObservableState, lhs._$id == rhs._$id { _rows = newValue } else { withMutation(keyPath: \.rows) { _rows = newValue } } } }
— 22:47
Now we get caught in the mutation of the rows identified array. This is happening because IdentifiedArray does not conform to ObservableState , and so in that case we don’t get the ability to elide calling withMutation . We must call withMutation .
— 23:06
And this over-rendering problem is exactly what motivated the creation of the ForEachStore in the first place. We were able to squirrel away this little bit of special logic: removeDuplicates: { areOrderedSetsDuplicates($0.ids, $1.ids) }
— 23:16
…that makes sure the ForEach is re-rendered only when the IDs in the identified array changes, either by adding or removing elements, or by re-arranging elements.
— 23:19
We need someway to capture this in the new observation world. We need the concept of being able to assert additional logic for eliding withMutation when we are dealing with an identified array.
— 23:34
Now, we might think we could just conform IdentifiedArray to ObservableState : extension IdentifiedArray: ObservableState { public var _$id: UUID { <#code#> } }
— 23:44
But then we are forced to answer an unconformable question. We need to somehow distill the identity of an entire collection of elements down to a single UUID.
— 23:59
That’s not really possible so we would probably have to beef up the ObservableState protocol to be an array of UUIDs: var _$id: [UUID] { get }
— 24:06
But even that is not correct because identified arrays can use any type as their ID, so I guess we would want to erase things entirely: var _$id: AnyHashable { get }
— 24:17
That severely complicates the notion of structural identity, and we don’t think it’s worth going down that path.
— 24:22
There’s another way to allow customization points for eliding withMutation . What if we had a global, generic function for determining whether two values have the same “identity”: public func _$isIdentityEqual<T>( _ lhs: T, _ rhs: T ) -> Bool { if let lhs = lhs as? ObservableState, let rhs = rhs as? ObservableState { return lhs._$id == rhs._$id } else { return false } }
— 24:57
We are prefixing this with _$ to signify that it’s mostly an implementation detail related to macros, but it does need to be public because the macro needs to be able to access it.
— 25:40
This is basically the same logic the @ObservableState macro inserts into set accessors. And so we could update the macro to call out to this function instead of performing the work directly inline: let setAccessor: AccessorDeclSyntax = """ set { if _$isIdentityEqual(_\(identifier), newValue) { _\(identifier) = newValue } else { withMutation(keyPath: \\.\(identifier)) { _\(identifier) = newValue } } } """
— 26:54
With that change the demos should work exactly as before. Nothing has really changed but moved some code out of the macro and into a global function.
— 27:00
But now for the very cool trick. We can provide an overload that is more specialized for dealing with specific situations that we want to optimize. For example, when checking if two identified arrays have the same identity, we only want to check that their ordered set of IDs is identical: public func _$isIdentityEqual<T>( _ lhs: IdentifiedArrayOf<T>, _ rhs: IdentifiedArrayOf<T> ) -> Bool { areOrderedSetsDuplicates(lhs.ids, rhs.ids) }
— 27:46
At compile-time Swift will get a chance to choose between these two versions of _$isIdentityEqual . If the field being modified with the @ObservableStateTracked macro is an identified array, then it will just this overload, and otherwise it will choose the more generic one. This is a static guarantee and not performed dynamically at runtime at all, which is great for performance.
— 28:06
And it completely fixes the problem of over-rendering that we noticed a minute ago. First we can see that the demo works exactly as it did before. Adding, removing, incrementing, decrementing, and parent observation of the first row all works perfectly. But even better, incrementing any counter but the first produces just a single log to the console: BasicsView: @dependencies changed.
— 28:46
And only if we incrementing the first row does the parent view need to re-compute its body: BasicsView: @dependencies changed. IdentifiedListView: @dependencies changed.
— 28:55
We can also put a breakpoint in this new _$isIdentityEqual overload: public func _$isIdentityEqual<T>( _ lhs: IdentifiedArrayOf<T>, _ rhs: IdentifiedArrayOf<T> ) -> Bool { areOrderedSetsDuplicates(lhs.ids, rhs.ids) } …and run the app in the simulator.
— 29:07
…to see that indeed we are caught in this breakpoint. This shows that we are able to insert small bits of specialized logic to control when we elide informing the registrar of mutations, and thus saving us view renders that are not necessary. New ForEach super powers
— 29:26
And so not only have we completely gotten rid of a dedicated view helper for collections, the ForEachStore , but we’ve even made the views more efficient with a trick that guarantees static dispatch, which reduces view re-computation by even more. Brandon
— 29:40
But even better, now that we are using simpler tools to render a collection of items in a view, we get access a whole suite of other tools that Swift provides for us, all for free. In particular, now that we are using a plain Array of stores with a plain SwiftUI ForEach view, we get the ability to transform, modify and inspect that collection in ways that were previously impossible.
— 30:07
Let’s take a look.
— 30:09
There is a common pattern in lists of data where you want to alternate the color of the background of each cell. This gives a little variation to an otherwise monotonous list of data, and can make it easier to visually scan through the view.
— 30:24
However, with the ForEachStore this was not really possible unfortunately. To use ForEachStore we had to scope our store down to the domain of a collection of row domains and hand it off to ForEachStore : ForEachStore( store.scope(state: \.rows, action: \.rows) ) { store in // ... }
— 30:43
And then somewhere deep in the bowels of ForEachStore it took care of constructing the collection of data to hand to a plain ForEach . This means we never got direct access to the collection of data that powered the list, and hence could never get access to the index of each element so that we could change the background color: ForEachStore( store.scope(state: \.rows, action: \.rows) ) { store in … .background { Color.black.opacity( offset.isMultiple(of: 2) ? 0.05 : 0 ) } }
— 31:36
Many people have requested we extend the ForEachStore type to also provide an index of the element: ForEachStore( store.scope(state: \.rows, action: \.rows) ) { index, store in … }
— 31:51
But overloads like that can be very taxing on the Swift compiler. So what we really would have had to do is provide a whole new view helper just for getting access to the index: ForEachStoreWithIndex( store.scope(state: \.rows, action: \.rows) ) { index, store in … }
— 32:19
That means we would have to duplicate the hundreds of lines code, and maintain lots more documentation, just to support something so simple.
— 32:33
Well, now that we are using simpler tools we get a very easy way to do this, and looks pretty much exactly like what you would do in vanilla SwiftUI too. We can use the enumerated method on Array to get access to offset of each element in the array: ForEach( store.scope(state: \.rows, action: \.rows) .enumerated() ) { offset, store in … }
— 33:09
However, this doesn’t compile because the special kind of sequence returned from enumerated does not confirm to RandomAccessCollection : Generic struct ‘ForEach’ requires that ‘EnumeratedSequence<[StoreOf<BasicsView.Feature>]>’ conform to ‘RandomAccessCollection’
— 33:19
So, you are forced to turn it into such a collection by wrapping it in an array: ForEach( Array( store.scope(state: \.rows, action: \.rows) .enumerated() ) ) { offset, store in … }
— 33:25
But then you are met with a different compiler error: Type ‘EnumeratedSequence<[Int]>.Element’ (aka ‘(offset: Int, element: StoreOf<BasicsView.Feature>)’) cannot conform to ’Identifiable’
— 33:27
…letting you know that the element type of EnumeratedSequence does not conform to Identifiable . And the element can’t possibly be identifiable because it’s actually a tuple, and tuples cannot currently conform to protocols. But even if they could, there is no one obviously correct, canonical way to make this tuple identifiable.
— 33:53
And so you have to further provide a key path to isolate the data inside the tuple that will serve as the ID for the ForEach view. And since the store is Identifiable , we can use its ID: ForEach( Array( store.scope(state: \.rows, action: \.rows) .enumerated() ), id: \.element.id ) { offset, store in … }
— 34:26
That’s all it takes, and this code is now compiling and we immediately have access to the offset for each row in the list. We did have to do a small amount of work to get this going, but it’s the exact same work you would have to do in vanilla SwiftUI too. So it’s great to see that Composable Architecture code can look more similar to vanilla SwiftUI code.
— 34:49
And now that we have the offset we can alternate the color of the background of each row in the list: .background { Color.black.opacity(offset.isMultiple(of: 2) ? 0.05 : 0) } … .listStyle(.plain)
— 35:26
That’s all it takes, and previously this just wasn’t possible with ForEachStore since the actual collection of stores was hidden from you behind the scenes.
— 35:35
And this is only scratching the surface of how powerful this can be. Because the collection of stores is surfaced to us directly in the view, and not hidden away, we have the full suite of collection APIs from the standard library at our disposal.
— 35:49
For example, what if we wanted to separate out the first 3 rows in a special section of the list, and then the rest of the collection in a separate selection? We can simply use prefix and dropFirst to accomplish this now: Section { ForEach( store.scope(state: \.rows, action: \.rows) .prefix(3) ) { store in … } } header: { Text("Top picks") } Section { ForEach( store.scope(state: \.rows, action: \.rows) .dropFirst(3) ) { store in … } } header: { Text("You might also like") }
— 37:28
This is pretty incredible. Previously you would have had to this kind of partitioning logic in the reducer and maintained two separate collections of data. Honestly, that would have been so difficult it probably would just not have been worth it at all.
— 37:41
But now we can do it so simply. There is one strange thing though, which is that the sections show even if the section is empty. But that’s an easy fix too, we can just check the count of the rows to determine which section should be shown: if !store.rows.isEmpty { Section { … } header: { Text("Top picks") } } if store.rows.count > 3 { Section { … } header: { Text("You might also like") } }
— 38:23
And now this works as we expect. And even better, we are still observing state in the most minimal way possible:
— 38:51
Adding a row causes the IdentifiedListView to re-compute, which isn’t surprising because the root list did change, as well as a BasicsView , which also isn’t surprising because a new row must be displayed.
— 39:10
Incrementing any row except the first only causes a single BasicsView body to be computed, which is great since the root list view does not care about that data at all.
— 39:24
Incrementing the first row does cause that single row to re-render, but also the root list. And that’s to be expected since something did change in the root list view, but no other rows needed to re-compute their bodies.
— 39:45
And so this of course seems great, but it is actually a lot more impressive than it may seem at first. The most impressive part of this minimal observation is these two conditionals right here: if !store.rows.isEmpty { … } if store.rows.count > 3 { … }
— 40:00
We are accessing the full collection of rows so that we can compute its count and figure out which sections need to be shown. And that seems innocent enough, but we saw back in our deep-dive into Swift 5.9’s observation tools that this was a death sentence for minimal observation.
— 40:24
In vanilla SwiftUI, the moment you touch a value type field of an @Observable model in a view, you have unwittingly told SwiftUI that you care about all changes to that value type. Even if in reality you only care about a small part of the value type. And we showed this very concretely with a tab view that uses data from one of its tabs to badge the tab item at the bottom of the screen.
— 40:48
In fact, let’s pull up that project right now.
— 41:01
And I’ll run the preview.
— 41:03
I’m going to switch to the 3rd tab, which contains a very simple feature that allows you to add rows to a list, each row contains a number, and you can increment and decrement that number.
— 41:13
Further, the count value of the first row is used as the badge of the 3rd tab in the parent feature. And the parent view accomplishes this in the most naive way possible, by reach right into the third tab model, through to the numbers array, plucking out the first element, and grabbing the count in there: .badge(model.tab3.numbers.first?.count ?? 0)
— 41:40
Seems innocent, but is actually not so innocent at all. That seemingly innocuous badge has made the parent view subscribe to all changes in the numbers array. This means every time a row is added the parent view will re-render. If any counter is changed, not just the first, then again the parent view will re-render.
— 42:18
This seems really counterintuitive at first, and it can definitely lead you to accidentally over-rendering your views if you are not careful. And the reason this is happening is because observation in SwiftUI is only as granular as you apply the @Observable macro. In this situation only the 3rd tab model is using @Observable and so observation only gets granular to that one level, but does not further get granular inside the numbers array. And it can’t get any more granular because the observation tools don’t really work with value types. They only work with reference types.
— 43:02
But, over in the Composable Architecture we are not experiencing this issue at all. In our library, observation works wonderfully with value types, and we can access deeply nested state without feature of accidentally observing too much state. It all just works great!
— 43:27
However, there is one thing that’s not so great about this. Our scope operation returns a fully realized, eager array: ) -> [Store<ElementState, ElementAction>] {
— 43:40
This means that if we just wanted to show only the first 3 elements of the array, and not the tail of the collection at all, then we would be eagerly creating an array of all scoped child stores even though the view just wants a few of them.
— 43:55
And we can even see this directly if we quickly comment out the second section in the view: /* if self.store.rows.count > 3 { … } */
— 44:04
Then run the preview, and filter the logs in the preview for “.init”. This will show us whenever a store is initialized in the preview.
— 44:12
We will see that each new row we add, a single store is initialized. And that is to be expected, at least for the first 3 rows. But then after that we would hope that no stores are created since they aren’t even being used in the view. But sadly, that is not the case. Every single time we tap “Add” a store is being created, even though that row isn’t even being displayed.
— 44:36
Luckily we can fix this very quickly. Instead of returning a fully realized Array from the scope operation, we will return a custom collection type that will essentially make the child scoping operation lazy.
— 44:55
We will return a kind of StoreCollection type, and it will need to be generic over the child domain, including its ID since under the hood it needs to deal with identified arrays: ) -> StoreCollection< ElementID, ElementState, ElementAction > {
— 45:25
So, we can start by defining a new type with those generics, and we will want it to conform to the RandomAccessCollection protocol since that is what ForEach requires: public struct StoreCollection< ID: Hashable, State, Action >: RandomAccessCollection {
— 45:48
This collection is going to need to hold onto the underlying store focused in on the identified child domain so that it can implement the requirements of RandomAccessCollection : public struct StoreCollection< ID: Hashable, State, Action >: RandomAccessCollection { private let store: Store< IdentifiedArray<ID, State>, IdentifiedAction<ID, Action> > }
— 46:33
And there are 3 main requirements we have to implement for the RandomAccessCollection protocol. We need a start and end index for the collection: public var startIndex public var endIndex
— 46:43
And we need a subscript that will return a store focused in on the child domain: public subscript(position: <#???#>) -> Store<State, Action> {
— 46:48
The index of this collection type will just come from the underlying identified array that is in the store , and so that is just Int : public var startIndex: Int { } public var endIndex: Int { }
— 47:07
And we want to just defer to the underlying identified array for these values, but it is not right to reach directly into the store like this: public var startIndex: Int { self.store.stateSubject.ids.startIndex } public var endIndex: Int { self.store.stateSubject.ids.endIndex }
— 47:32
Doing so makes this collection behave like a reference type. It means that the endIndex can change at anytime without the collection being mutated at all, and that wreaks havoc on SwiftUI’s ability to properly diff and snapshot the data source so that it can figure out when to re-render the view.
— 48:02
Instead we are going to snapshot the ids right when the collection is created: private let store: Store< IdentifiedArray<ID, State>, IdentifiedAction<ID, Action> > private let ids: OrderedSet<ID> init( _ store: Store< IdentifiedArray<ID, State>, IdentifiedAction<ID, Action> > ) { self.store = store self.ids = store.stateSubject.ids } …and then use that in the startIndex and endIndex methods: public var startIndex: Int { self.ids.startIndex } public var endIndex: Int { self.ids.endIndex }
— 48:57
And we can perform the child store scoping right in the subscript: public subscript(position: Int) -> Store<State, Action> { let id = self.ids[position] return self.store.scope( state: { $0[id: id]! }, id: { _ in id }, action: { .element(id: id, action: $0) }, isInvalid: { !$0.ids.contains(id) }, removeDuplicates: nil ) }
— 50:23
That’s all it takes to make this new collection type, and we can return it from the scope operator: public func scope<ElementID, ElementState, ElementAction>( state: KeyPath< State, IdentifiedArray<ElementID, ElementState> >, action: CaseKeyPath< Action, IdentifiedAction<ElementID, ElementAction> > ) -> StoreCollection< ElementID, ElementState, ElementAction > { StoreCollection(self.scope(state: state, action: action)) }
— 50:48
Everything now builds, even our integration test case, but now the store initialization behavior is a lot better. We can add a bunch of rows to the view to see that only 3 stores are actually created since that is all that is displayed on the screen. Next time: Observable navigation
— 52:10
By getting rid of our specialized view helper, the ForEachStore , we have unlocked all new super powers for when modeling lists of features in the Composable Architecture. We get to use all of the fancy collection APIs that come with the standard library in order to slice and dice a collection of data for display in the UI, and everything is still rendered in the most minimal way possible.
— 52:31
So, we have now gotten rid of the WithViewStore , IfLetStore , SwitchStore , CaseLet and ForEachStore . Those were the only dedicated views that the library shipped in order to deal with optional state, enum state, and collection state. Stephen
— 52:45
But there’s another class of helpers that ship with the library that are also no longer needed in the world of Swift observation, and that’s navigation view modifiers. We currently have to maintain a whole zoo of view modifiers that mimic ones that come with vanilla SwiftUI but tuned specifically for the Composable Architecture. This includes modifiers for sheets, popovers, fullscreen covers, drill-downs, navigation stacks, and more.
— 53:12
Well, amazingly, we can stop using all of those helpers and instead just use the vanilla SwiftUI view modifiers. And it’s all thanks to the observation tools in Swift.
— 53:22
Let’s take a look. Downloads Sample code 0263-observable-architecture-pt5 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 .