Video #265: Observable Architecture: Observing Bindings
Episode: Video #265 Date: Jan 22, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep265-observable-architecture-observing-bindings

Description
We have iterated on how bindings work in the Composable Architecture many times, but have never been fully happy with the results. With Observation, that all changes. By eliminating view stores and observing store state directly, we are free to totally reimagine bindings in the Composable Architecture, and get rid of even more concepts in the process.
Video
Cloudflare Stream video ID: b290e732710cfccd2d43321942c3cfc9 Local file: video_265_observable-architecture-observing-bindings.mp4 *(download with --video 265)*
Transcript
— 0:05
We have now see that the new observation tools in Swift 5.9 has revolutionized nearly every part of the Composable Architecture. We have been able to completely remove large swaths of concepts that previously were required to make efficient features, and replace it with far more vanilla Swift and SwiftUI constructs. And we can now do less work to implement our features while somehow magically making our features more correct and more performant.
— 0:30
There is one last area of the Composable Architecture that we want to show off to see how the new observation tools can improve the situation. And this is an area of the Composable Architecture that has been thorny from day 1. And that is bindings. Stephen
— 0:43
When we first released the Composable Architecture we did not provide any special tools for bindings, which mean that your reducers would become very verbose since you would need a dedicated action for each UI component that uses bindings, and you would need to handle all those actions in the reducer.
— 0:59
Eventually we did provide some tools that made the situation a lot better, but there were lots of caveats to those tools. And we have multiple times tried to soften those caveats and fill in the gaps, and multiple times we have failed to come up with something that we were truly happy with.
— 1:13
Well, the new Observation tools in Swift 5.9 finally allow us to implement bindings in the library how we hoped we could from the very first days of the Composable Architecture. We again get to remove superfluous concepts from the library, and use simpler, more familiar constructs.
— 1:28
Let’s take a look. Simpler bindings in theory
— 1:32
We have a case study in the repo that demonstrates the current way of deriving bindings to piece of state in a feature. Let’s hop to 01-GettingStarted-Bindings-Forms.swift to see what it entails.
— 1:46
If we run the preview we see a simple form with a bunch of controls. The toggle disables the full form, and the stepper sets the maximum bound for the slider. And finally there’s a reset button that puts the form back in its default state.
— 2:15
It starts with the State of the feature, where we must annotate the fields for which we want to be able to easily derive bindings for: struct State: Equatable { @BindingState var sliderValue = 5.0 @BindingState var stepCount = 10 @BindingState var text = "" @BindingState var toggleIsOn = false }
— 2:24
In this case it happens to be every field, but often that is not the case.
— 2:28
Then we conform our Action type to the BindableAction protocol, which requires us to provide a case that holds a BindingAction : enum Action: BindableAction, Equatable { case binding(BindingAction<State>) case resetButtonTapped }
— 2:44
Then we compose the BindingReducer into our feature’s body so that it can handle mutating the proper field when a binding action comes into the system: var body: some Reducer<State, Action> { BindingReducer() Reduce { state, action in … } }
— 2:53
We also have a place in the core reducer where we want to detect whenever the stepCount is changed through the binding so that we can use that value to keep the sliderValue between 0 and the step count. We can do this by pattern matching on the binding case of our action enum, and specifying a key path to the binding state we are interested in: case .binding(\.$stepCount): state.sliderValue = .minimum( state.sliderValue, Double(state.stepCount) ) return .none
— 3:12
That’s all it takes in the reducer layer.
— 3:14
In the view layer we have to observe the state by using WithViewStore : var body: some View { WithViewStore(store, observe: { $0 }) { viewStore in … } }
— 3:21
And once we have a view store we are able to derive bindings to any of the @BindingState fields by using $ syntax. For example, a text field: TextField("Type here", text: viewStore.$text)
— 3:31
…or a stepper: Stepper( "Max slider value: \(viewStore.stepCount)", value: viewStore.$stepCount, in: 0...100 )
— 3:36
…or a slider: Slider( value: viewStore.$sliderValue, in: 0...Double(viewStore.stepCount) )
— 3:39
So, that doesn’t seem so bad I suppose. Sure there are a few steps to take to get things setup, but then you get a very short syntax in the view to derive bindings.
— 3:48
Well, unfortunately this simple case study does not properly demonstrate real world applications that use bindings. For example, what if this feature can navigate to another feature, and so we have to hold its state inside this state: struct State: Equatable { … @PresentationState var child: NavigationDemo.State? }
— 4:10
Well, that’s a problem because down in the view we have observed all of the state in the feature: WithViewStore(store, observe: { $0 }) { viewStore in … }
— 4:16
This is going to cause this view to re-compute its body anytime something changes in the child feature, even if this view isn’t using anything from that child feature.
— 4:26
We might hope we can just introduce some view state that holds onto only the state the view cares about, which is the 4 binding variables: struct ViewState: Equatable { let sliderValue: Double let stepCount: Int let text: String let toggleIsOn: Bool init(state: BindingForm.State) { self.sliderValue = state.sliderValue self.stepCount = state.stepCount self.text = state.text self.toggleIsOn = state.toggleIsOn } }
— 5:08
And then we would observe that state: WithViewStore( store, observe: ViewState.init ) { viewStore in … }
— 5:16
But as soon as we do that the view no longer compiles, and we get a cryptic compiler message, but it’s all due to the fact that this syntax no longer works: viewStore.$text
— 5:24
The ViewState has lost all the information of @BindingState , and so this cannot work.
— 5:29
And unfortunately the fix is really, really gross. We have to mark each field in the ViewState that needs bindings with @BindingViewState : struct ViewState: Equatable { @BindingViewState var sliderValue: Double @BindingViewState var stepCount: Int @BindingViewState var text: String @BindingViewState var toggleIsOn: Bool … }
— 5:41
And we have to update the init to take a BindingViewStore and assign the store properties using _ and by accessing the $ -prefixed properties in the binding view store: init(bindingViewStore: BindingViewStore<BindingForm.State>) { self._sliderValue = bindingViewStore.$sliderValue self._stepCount = bindingViewStore.$stepCount self._text = bindingViewStore.$text self._toggleIsOn = bindingViewStore.$toggleIsOn }
— 6:09
And now suddenly this feature is compiling, and will work exactly as it did before. But this code is an aberration. It turns out bindings is a pretty thorny problem, and these tools are the best we’ve been able to come up with so far, but we of course are not very happy with it.
— 6:31
Let’s update this code to be a version of what we would ideally like to write for deriving bindings from the store, and then see how we can achieve it.
— 6:39
First of all, we want to use the new @ObservableState machinery, so let’s start by applying the macro: @ObservableState struct State: Equatable { … }
— 6:47
And we already know that macros don’t play nicely with property wrappers, so rather than supporting the concept of granularly being able to decide which fields are bindable, what if we just got rid of the concept all together: @ObservableState struct State: Equatable { var sliderValue = 5.0 var stepCount = 10 var text = "" var toggleIsOn = false @PresentationState var child: NavigationDemo.State? }
— 6:57
After all, even a vanilla, observable model doesn’t have the concept of “granular” bindings. If your model exposes a writable property, you get the ability to derive a binding to it.
— 7:12
Then in the reducer where we want to detect when the binding action for stepCount is sent, we can just drop the $ : case .binding(\.stepCount): state.sliderValue = .minimum( state.sliderValue, Double(state.stepCount) ) return .none
— 7:23
Then in the view we would hope we can completely get rid of the ViewState .
— 7:29
And we would want to drop the WithViewStore entirely so that we could just have a Form at the root of the view: var body: some View { Form { … } }
— 7:36
Then, to derive a binding from the store we would hope we could just use $ syntax on the store, since it is held as @State , just as we would do in vanilla SwiftUI: // TextField("Type here", text: viewStore.$text) TextField("Type here", text: $store.text)
— 7:46
And let’s quickly update the rest of the view to use the store rather than viewStore : Simpler bindings made a reality
— 8:08
This view is now far simpler and looks more like an average, vanilla SwiftUI view. We can access state directly in the store, and we can derive bindings directly from the store using $ syntax. The only real difference between this and vanilla SwiftUI is that we send actions to the store rather than call methods on the store. Brandon
— 8:28
So, what tools do we need to add to the library to make this code compile? Let’s take a look.
— 8:37
Let’s start with making this syntax valid: $store.text
— 8:44
The type of $store is a Binding of a Store of the BindingForm feature: let _: Binding<StoreOf<BindingForm>> = $store
— 9:06
The Binding type has dynamic member lookup defined on it so that you can dot-chain onto a binding with any property that is defined on Store .
— 9:27
And thanks to the dynamic member lookup defined on store , we do actually have quite a few properties defined. Basically every property in the feature’s state magically becomes a property on the store, like this: let _: String = store.text
— 9:51
So maybe that means we can just do this to get a binding to that property: let _: Binding<String> = $store.text
— 10:05
Well, unfortunately that does not work: Cannot assign through dynamic lookup property: subscript is get-only
— 10:12
The dynamic member lookup in Binding doesn’t work with just any property. It works exclusively with writable properties. But the dynamic member we defined on our Store so far is not writable, only readable: extension Store where State: ObservableState { public subscript<Member>( dynamicMember keyPath: KeyPath<State, Member> ) -> Member { self.state[keyPath: keyPath] } }
— 11:02
And for good reason. If we made this writable then it would allow people to make changes to the state in the store without sending an action. And the fact that the only time state is allowed to change is via sending an action is a big part of the Composable Architecture. It’s what makes it a “single entry point system”, and that is where a lot of the power of the library comes from.
— 11:29
So, we definitely do not want to upgrade this to a full blown writable property. However, under certain conditions it would be OK for this to be writable. In particular, when the Store ’s action is a BindableAction , because then that gives a way to send an action when one tries to write through the subscript.
— 11:53
So, let’s extend the Store type when the State is ObservableState and the Action is BindableAction : extension Store where State: ObservableState, Action: BindableAction, Action.State == State { }
— 12:22
And we will add a public dynamic member subscript that takes a writable key path from the store’s state to some other value: public subscript<Value: Equatable>( dynamicMember keyPath: WritableKeyPath<State, Value> ) -> Value { }
— 12:34
And crucially, this subscript will be get and set : public subscript<Value: Equatable>( dynamicMember keyPath: WritableKeyPath<State, Value> ) -> Value { get { } set { } }
— 12:45
The get is quite easy, we can just key path into the store’s observable state: get { self.state[keyPath: keyPath] }
— 12:50
And then in the set we know we want to send an action rather than mutating state directly, so we can start with that: self.send(<#BindableAction#>)
— 13:05
Then we can use Xcode’s autocomplete to see what our choices are here self.send(.<#⎋#>)
— 13:07
We want to send a binding action so we can start there: self.send(.binding(<#BindingAction<State>#>))
— 13:12
There’s a set action that can be sent, that allows you to supply a key path and a value for setting: self.send( .binding( .set( <#WritableKeyPath<State, BindingState<Equatable>>#>, <#Equatable#> ) ) )
— 13:21
And so maybe we can just pass along our key path and the newValue being set: self.send(.binding(.set(keyPath, newValue)))
— 13:44
Well, unfortunately that does not work because the set method apparently requires that you are key pathing into some BindingState : Cannot convert value of type ‘WritableKeyPath<State, Value>’ to expected argument type ‘WritableKeyPath<State, BindingState<Value>>’
— 13:55
And that’s the property wrapper we are trying to get rid of.
— 14:02
Sounds like we need another version of this set method that deals with plain values and not BindingState . We can even jump to the implementation of set : extension BindingAction { public static func set<Value: Equatable & Sendable>( _ keyPath: WritableKeyPath<Root, BindingState<Value>>, _ value: Value ) -> Self { return .init( keyPath: keyPath, set: { $0[keyPath: keyPath].wrappedValue = value }, value: value ) } }
— 14:17
And copy and paste it into our Store+Observation.swift file.
— 14:28
…and make a few small changes.
— 14:30
First, the key path provided does not need to project down into BindingState : _ keyPath: WritableKeyPath<Root, Value>,
— 13:33
And this method would be constrained to only work with ObservableState : where Root: ObservableState
— 14:40
Then in the body of this method we will call out to the internal initializer: .init( keyPath: <#PartialKeyPath<ObservableState>#>, set: <#(inout ObservableState) -> Void#>, value: <#AnySendable#>, valueIsEqualTo: <#(Any) -> Bool#> ) We have to do some strange things to initialize a BindingAction because we need to erase the value type that is being set while still maintaining equatability of BindingAction . And on top of that we want to maintain sendability of the type too.
— 14:47
And so that is why we are dealing with a PartialKeyPath and have some Any types in this signature. For the first argument we can just pass along the key path: keyPath: keyPath,
— 14:49
The second argument is the closure that actually does the setting work when the binding action is sent into the system, and so we can just key path in and mutate: set: { $0[keyPath: keyPath] = value },
— 15:00
The value argument is just to hold onto the value being set so that we can improve diagnostics when something goes wrong: value: AnySendable(value),
— 15:13
And the last argument is to aid in equatability of BindingAction since it is holding onto a closure, which is not an equatable type: valueIsEqualTo: { ($0 as? AnySendable)?.base as? Value == value }
— 15:36
With that done the only thing not compiling is where we try to pattern match on the binding action being handled so that we can insert additional logic: case .binding(\.stepCount):
— 15:55
The previous version of this, with the $ , worked because of the following ~= operator defined in the library: extension Binding { public static func ~= <Value>( keyPath: WritableKeyPath<Root, BindingState<Value>>, bindingAction: Self ) -> Bool { keyPath == bindingAction.keyPath } }
— 16:26
Notice that it works specifically with BindingState . Let’s copy-and-paste this method into Store+Observation.swift so that we can drop the BindingState : extension BindingAction { public static func ~= <Value>( keyPath: WritableKeyPath<Root, Value>, bindingAction: Self ) -> Bool where Root: ObservableState { keyPath == bindingAction.keyPath } }
— 16:40
And with that everything is compiling, and it all works exactly as it did before.
— 17:12
And this shortened, more vanilla looking syntax would just not have been possible without the Observable machinery from Swift 5.9. This short, succinct syntax for a binding: $store.text
— 17:30
…is both reading state from the store and sending actions when the binding is written to. That simply does not work in the version of the library that is on main of the repo right now. Any changes to the state would not be observed, and so we were forced to come up with all kinds of tools to try to make bindings as ergonomic as possible, while still playing nicely with the core tenets of the library. Next time: The point
— 18:03
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
— 18:21
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.
— 18:33
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.
— 18:51
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…next time! Downloads Sample code 0265-observable-architecture-pt7 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 .