EP 268 · Shared State · Feb 26, 2024 ·Members

Video #268: Shared State: The Problem

smart_display

Loading stream…

Video #268: Shared State: The Problem

Episode: Video #268 Date: Feb 26, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep268-shared-state-the-problem

Episode thumbnail

Description

We tackle one of the biggest problems when it comes to “single source of truth” applications, and that is: how do you share state? Let’s begin by analyzing the problem, and truly understanding what vague mantras like “single source of truth” even mean, and then we will be in a good position to provide a wonderful solution.

Video

Cloudflare Stream video ID: a6065e393aedd06cfdf8821c860de6cb Local file: video_268_shared-state-the-problem.mp4 *(download with --video 268)*

Transcript

0:05

Today we are starting a series of episodes to address one of the most common questions we get from people using the Composable Architecture, and that is: how do you share state amongst many features?

0:15

Now that question is surprisingly subtle. The Composable Architecture boasts having a “single source of truth”, and so shouldn’t that mean state is automatically shared? If you change one piece of state in the root store, doesn’t that mean every view sees that state immediately? Stephen

0:31

Well, that’s kind of true. In reality one tends to break a large, complex app into many features that can be run independently. In that situation, most, if not all, features do not have access to the entire app state. They only see a small portion of their state. And this is a good thing, as it encourages modularity and isolation.

0:49

But then the question is: how do you share the same piece of state amongst many independent, modularized features? We have a case study that demonstrates one way of attacking the problem, but to be honest we don’t really recommend people follow that style. It takes a lot of work, it’s easy to get wrong, and it’s easy to accidentally break the nice new observation tools we just recently released. Brandon

1:07

In various discussion forums we have told people that another way of handling shared state is via the dependency system. This allows you to immediately propagate a piece of state to all reducers, but we never created a case study to demonstrate this style, and it also has a lot of drawbacks.

1:25

And so we are finally ready to give some guidance on how to do this, and the solution is actually quite wonderful. It’s all made possible thanks to Swift’s new observation machinery, and it forces us to really grapple with what vague mantras like “single source of truth” really mean, and forces us to really understand the role of reference types and value types in our application. Stephen

1:49

And we have also gone above and beyond in these tools by providing two huge capabilities:

1:54

First, shared state is going to be completely testable. It can even be exhaustively tested. If that doesn’t sound impressive to you right now, then wait to you see how we implemented it. 🙂 Brandon

2:04

And second, we are going to provide the tools necessary to persist state automatically. This means any changes to the state will be automatically saved externally and available on next launch of the application. And there will be multiple sharing strategies, such as user defaults, file storage, and more. This will look quite similar to how the @AppStorage property wrapper works in SwiftUI, but it will be much better because it will be embedded directly in your Composable Architecture feature, and of course 100% testable.

2:43

We have a lot to cover, so let’s dig in! The problem of shared state

2:47

Let’s first begin by really understanding what “shared state” is, and why it can be so difficult in the Composable Architecture. The library even comes with a case study named “Shared State”, and it’s been there since day 1 of the Composable Architecture, for nearly 4 years! However, we were never really happy with this technique and don’t really recommend it beyond very simple situations. It requires a lot of coordination that is easy to get wrong.

3:13

Let’s run it in the preview to see what it is demonstrating.

3:17

It’s a simple tab-based application with a counter feature in the first tab. We can simply increment and decrement it, but behind the scenes it is secretly computing some stats about the counter, such as the total number of times we interact with it, as well as the maximum and minimum value the counter has seen.

3:29

Those stats aren’t shown anywhere on the first tab though. To see those stats we have to go to the second tab, the “Profile” tab. Over there we see all of the stats, and there is only one thing we can do with the stats is look at them, and reset them. If we reset them we will see all the data goes back to 0, but also if we switch back to the “Counter” tab we will see that the counter over there has also been set to 0.

3:59

Now, this isn’t too impressive, but it is our way of distilling down a common problem and showing one way of solving it, but as I mentioned a moment ago, we aren’t exactly thrilled with the technique. Let’s jump to the code to see how we accomplish this.

4:15

It starts off innocently enough. At the top of the file are the features for each independent tab. For example, the CounterTab feature holds onto some alert state and stats in order to do its job: @Reducer struct CounterTab { @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? var stats = Stats() } … }

4:27

…where the Stats type is just a simple value type that encapsulates the logic of tracking the max, min and total number of counts in one package: struct Stats: Equatable { private(set) var count = 0 private(set) var maxCount = 0 private(set) var minCount = 0 private(set) var numberOfCounts = 0 mutating func increment() { count += 1 numberOfCounts += 1 maxCount = max(maxCount, count) } mutating func decrement() { count -= 1 numberOfCounts += 1 minCount = min(minCount, count) } mutating func reset() { self = Self() } }

4:52

This allows the CounterTab to increment and decrement the stats, and all the other state inside Stats will be updated accordingly. And having it packaged up in a separate type makes it easy to reuse over in the ProfileTab too.

4:57

Speaking of which, just below the CounterTab is the ProfileTab , and it also holds onto some Stats state: @Reducer struct ProfileTab { @ObservableState struct State: Equatable { var stats = Stats() } … }

5:05

This allows the profile view to display the stats, and also reset them. Right now the profile only has a need for this Stats value, but in the future as the feature evolves a lot more state could be added to this struct.

5:15

One of the nice things about how the CounterTab and ProfileTab features are designed right now is that we could break them out into their own, isolated modules and they could be compiled independently of each other. They would only need to depend on a 3rd module that holds the Stats type, but they wouldn’t need to depend on each other, which is great.

5:39

Then there’s a third feature that glues these two features together so that they can be run side-by-side in a tab view. This is the SharedState feature, and it holds onto the state for each tab, as well as the current tab: @Reducer struct SharedState { enum Tab { case counter, profile } @ObservableState struct State: Equatable { var currentTab = Tab.counter var counter = CounterTab.State() var profile = ProfileTab.State() } … }

5:58

This is pretty standard for tab-based features. The parent feature that handles the tab view will hold onto the state for the features in each tab. Similarly the Action enum holds the actions for each feature: enum Action { case counter(CounterTab.Action) case profile(ProfileTab.Action) case selectTab(Tab) }

6:11

Still nothing too surprising.

6:16

Where things start to get a little strange is in the reducer. It needs to compose in each of the CounterTab and ProfileTab reducers, as well as the core reducer for the tab feature, but then it has some strange onChange stuff: var body: some Reducer<State, Action> { Scope(state: \.counter, action: \.counter) { CounterTab() } .onChange(of: \.counter.stats) { _, stats in Reduce { state, _ in state.profile.stats = stats return .none } } Scope(state: \.profile, action: \.profile) { ProfileTab() } .onChange(of: \.profile.stats) { _, stats in Reduce { state, _ in state.counter.stats = stats return .none } } Reduce { state, action in switch action { case .counter, .profile: return .none case let .selectTab(tab): state.currentTab = tab return .none } } }

7:01

This is all 90% reasonable code. The Scope reducer is the primary way to run child reducers inside parent reducers, and you can compose many reducers together by simply listing a bunch of them right in the body , which is given a reducer builder context.

7:17

What is not so common in this kind of set up is that we are doing with these onChange reducer operators. They are listening for the changes in one tab’s stats field and then playing those changes back to the other tab’s stats .

7:37

What is this all about?

7:38

Well, we’ve decided to model the shared Stats state as a value type, and that’s because value types are fantastic ways of modeling simple data, and because the Composable Architecture prefers for features to be built with value types. This is for a variety of reasons, some of which are relevant today and some of which are not.

7:55

The primary reason to prefer value types by default is that they are simple, logic-less bags of data that are immediately understandable. It is impossible, as enforced by the compiler, for an outside system to change a value. For example, it is a compiler error to try to escape a value into a closure and make a mutation. Value types can only be mutated in very tight lexical scopes. Whereas anyone can make mutations to reference types at any time . This makes value types just infinitely more understandable than reference types.

8:28

And because they are just data with no internal behavior, they have a very natural notion of equality. Two values are equal only if all the data inside them are equal. This makes them great for testing because you can easily assert on data. And the fact that value types are copyable also makes them great for exhaustive testing, where you compare the before and after values to force one to assert on everything that changes in the value. The Composable Architecture uses this to great benefit and is one of the best features of the library. Stephen

9:01

But reference types have a very complicated relationship with equality. You can’t simply compare the data they hold on the inside because there is also behavior to contend with. How can you check if two references are equal when they are capable of making network requests on the inside? Do they become un-equal if one is performing the request and the other isn’t?

9:19

There really is no good way to answer that question, and so typically we just sidestep around it entirely by saying that references are equal only if they are literally the same reference. Brandon

9:29

So, that’s why we like value types, but also value types don’t exactly play nicely with the concept of “sharing” state. After all, when passing values around they are copied, and those copies are completely untethered from the original value. Mutations to the copy have no effect on the original whatsoever.

9:46

And for that reason we are employing some tricks to synchronize the copies.

9:50

If the CounterTab features makes a change to its stats property we replay those changes over to the ProfileTab feature: Scope(state: \.counter, action: \.counter) { CounterTab() } .onChange(of: \.counter.stats) { _, stats in Reduce { state, _ in state.profile.stats = stats return .none } }

9:56

And conversely if the ProfileTab feature makes changes to its stats property we will replay those changes over to the CounterTab feature: Scope(state: \.profile, action: \.profile) { ProfileTab() } .onChange(of: \.profile.stats) { _, stats in Reduce { state, _ in state.counter.stats = stats return .none } }

10:00

I guess this is just the price we have to pay to use value types and share data, but the worst part is that technically this isn’t even correct. This only synchronizes changes from one tab feature to the other. But what if the core reducer decides to make a change to the stats state?

10:21

For example, we could do something silly like increment the stats every time you change the tab selection. But, to even do that we have to make a strange choice by saying which stats do we want to increment? Do we want to mutate the one in the counter field or the profile field? I guess it doesn’t really matter since they should be synchronized, so let’s just choose one: case let .selectTab(tab): state.currentTab = tab state.counter.stats.increment() // state.profile.stats.increment() return .none

10:47

But unfortunately this doesn’t work. We can run it in the preview to see that switching to the “Profile” tab and back to the “Counter” tab did indeed increase the count by 2, yet the “Profile” tab didn’t see those changes at all. It’s not until we increment the counter that the synchronization logic finally kicks in, and then the profile tab correctly shows the stats.

11:19

The correct way to do this is to apply the onChange reducers to the entire composed reducer, not just each of the Scope s. To do that we need to wrap the whole thing in CombineReducers and then attach onChange to it: var body: some Reducer<State, Action> { CombineReducers { Scope(state: \.counter, action: \.counter) { CounterTab() } Scope(state: \.profile, action: \.profile) { ProfileTab() } Reduce { state, action in switch action { case .counter, .profile: return .none case let .selectTab(tab): state.counter.stats.increment() state.currentTab = tab return .none } } } .onChange(of: \.counter.stats) { _, stats in Reduce { state, _ in state.profile.stats = stats return .none } } .onChange(of: \.profile.stats) { _, stats in Reduce { state, _ in state.counter.stats = stats return .none } } }

11:41

Now the demo works as we expect, but it does highlight just how tricky this synchronization logic can be. It’s precarious enough that I’m not sure we can really recommend people use this approach in larger, more complex applications.

12:04

Now one of the big benefits of value types is their ease of testing. So, let’s look at the tests we have for this SharedState feature. The first test just shows that we can flip the current tab to the “Profile” and then back to the “Counter”: func testTabSelection() async { let store = TestStore(initialState: SharedState.State()) { SharedState() } await store.send(.selectTab(.profile)) { $0.currentTab = .profile } await store.send(.selectTab(.counter)) { $0.currentTab = .counter } }

12:26

If we run this we will see that we get two failures: A state change does not match expectation: … SharedState.State( _currentTab: .profile, _counter: CounterTab.State( _alert: nil, _stats: Stats( − count: 0, + count: 1, − maxCount: 0, + maxCount: 1, minCount: 0, − numberOfCounts: 0 + numberOfCounts: 1 ) ), _profile: ProfileTab.State( _stats: Stats( − count: 0, + count: 1, − maxCount: 0, + maxCount: 1, minCount: 0, − numberOfCounts: 0 + numberOfCounts: 1 ) ) ) (Expected: −, Actual: +)

12:30

This is happening because of that new logic we added to the feature. When we switched the tab we also incremented the stats of the counter feature, and then further that change was also synchronized to the profile feature.

12:43

This is a great test failure to have. We added to functionality to the feature and so of course we should be forced to update our tests to account for it. If we had used reference types then this kind of test may have passed just fine until we explicitly went in and added more assertions on the pieces of state we expected to change, but even so we could never do so exhaustively.

13:15

The fix is to tell the test store that we explicitly expect the counter stats and profile stats to increment: await store.send(.selectTab(.profile)) { $0.currentTab = .profile $0.counter.stats.increment() $0.profile.stats.increment() } await store.send(.selectTab(.counter)) { $0.currentTab = .counter $0.counter.stats.increment() $0.profile.stats.increment() }

13:26

And now the test passes.

13:30

And we also do something similar in the 2nd test of this suite, where we show that any interaction in each tab, such as incrementing or decrementing in the “Counter” tab or resetting in the “Profile” tab also causes both copies of stats to update accordingly: func testSharedCounts() async { let store = TestStore(initialState: SharedState.State()) { SharedState() } await store.send(.counter(.incrementButtonTapped)) { $0.counter.stats.increment() $0.profile.stats.increment() } await store.send(.counter(.decrementButtonTapped)) { $0.counter.stats.decrement() $0.profile.stats.decrement() } await store.send(.profile(.resetStatsButtonTapped)) { $0.counter.stats = Stats() $0.profile.stats = Stats() } }

13:41

And now tests are passing.

14:05

There is one other place we have publicly used this technique of copying values around for sharing data, and that’s in the SyncUps demo application. We built this application during the 1.0 tour of the Composable Architecture, and recently we did a live stream showing how to update that code base to use the new observation tools of the library.

14:27

The code base uses value types as much as possible, including the foundational type of a SyncUp : struct SyncUp: Equatable, Identifiable, Codable { let id: Tagged<Self, UUID> var attendees: IdentifiedArrayOf<Attendee> = [] var duration: Duration = .seconds(60 * 5) var meetings: IdentifiedArrayOf<Meeting> = [] var theme: Theme = .bubblegum var title = "" var durationPerAttendee: Duration { self.duration / self.attendees.count } }

14:47

It makes sense that this would be a struct because it’s just data. We do not want to cram behavior into this concept that can cause the data inside a SyncUp to change over time. We’d rather all of that be contained inside our reducers.

15:09

And then in the SyncUpsList feature, which is responsible for implementing the logic and behavior of the root list of sync-ups, we hold onto a collection of those SyncUp s: @Reducer struct SyncUpsList { … @ObservableState struct State: Equatable { @Presents var destination: Destination.State? var syncUps: IdentifiedArrayOf<SyncUp> = [] … } … }

15:17

Again IdentifiedArray is just a value type, and so is just plain data. There’s no behavior whatsoever. The behavior all lives in the reducer.

15:28

So, this is great for building a feature in a way that we can wholly understand in isolation. There’s no way for some outside system to come in and change this data right underneath our nose. The compiler absolutely forbids it. The only way for this data to change is if an action is sent into the system.

15:48

But, the drawback to that is that we need to manually manage synchronizing state in a few spots. For example, back in the root AppFeature reducer we actually have to listen for a syncUpUpdated delegate action that the detail feature can emit just so that we can play changes from that features back to the list feature: case let .syncUpUpdated(syncUp): state.syncUpsList.syncUps[id: syncUp.id] = syncUp return .none

16:24

And the way the delegate action is sent is that we employ an onChange reducer in the SyncUpDetail reducer so that anytime we detect a change in the data we can broadcast the delegate action: .onChange(of: \.syncUp) { oldValue, newValue in Reduce { state, action in .send(.delegate(.syncUpUpdated(newValue))) } }

16:39

Again this code is just kind of a bummer to maintain, and it doesn’t feel quite right. Sharing state with value types

16:44

So, I guess on the one hand it’s nice that we can use value types in our features, but when it came to sharing data the value types were a real pain. Value types just really aren’t amenable to the concept of sharing, and that becomes painfully clear when writing tests. We are needing to mutate many different pieces of state since values get copied around, and that’s really confusing, but at least the exhaustive test store has our back each step of the way. Stephen

17:07

So clearly this code is not ideal, and perhaps the real problem is just that the domain is not as concisely modeled as it could be. Let’s investigate a quick refactor of the domain to see if it helps things out.

17:20

The most obvious thing wrong with this code is that our domain is just not concisely modeled. We are holding onto two completely independent pieces of Stats state, once in the CounterTab : @Reducer struct CounterTab { @ObservableState struct State: Equatable { … var stats = Stats() } … }

17:32

…and again in the ProfileTab : @Reducer struct ProfileTab { @ObservableState struct State: Equatable { var stats = Stats() … } … }

17:35

This is imprecisely modeled because we have two independent pieces of state when in reality we just one piece of state and for each feature to share the state.

17:43

And a big goal of the Composable Architecture is to give you the tools you need to model your domains as concisely as possible. It’s why we ship tools that allow you to use enums for state-driven navigation rather than relying on a bunch of independent optionals, which causes your domain to explode with invalid states and leaks complexity into your features.

18:00

In this specific situation we are seeing the complexity leak into our features by having to implement custom synchronization logic, and then when we need to refer to stats state we have to choose where we access it from: case let .selectTab(tab): … state.counter.stats.increment() // state.profile.stats.increment() …

18:23

Perhaps a better way to model our domain is for the parent feature to hold onto the Stats state, and for it to project that state down into each tab feature instead. Let’s give that a shot.

18:33

We will add some Stats state to the SharedState reducer: @ObservableState struct State: Equatable { var currentTab = Tab.counter var counter = CounterTab.State() var profile = ProfileTab.State() var stats = Stats() }

18:38

And with that change we now have a better way to refer to stats , we just reach into the parent’s stats and forget about the stats in each of the tab features: case let .selectTab(tab): state.stats.increment() …

18:50

That seems pretty good, but now our synchronization logic gets more complicated. We now technically have three copies of the stats state to synchronize. The parent stats is kinda seen as the “true” stats , but at the end of the day each tab also needs access to that data, and so we need to synchronize between all the values.

19:08

We will update our onChange operators so that any changes in one of the tabs replays those changes back to the parent stats : .onChange(of: \.counter.stats) { _, stats in Reduce { state, _ in state.stats = stats; return .none } } .onChange(of: \.profile.stats) { _, stats in Reduce { state, _ in state.stats = stats; return .none } }

19:18

And then we will need another onChange that can replay any changes to the parent’s stats to each of the tabs: .onChange(of: \.stats) { _, stats in Reduce { state, _ in state.counter.stats = stats state.profile.stats = stats return .none } }

19:32

And the order of these onChange operators is important. We need the tab features to synchronize their state to the parent before the parent synchronizes back to the tab features.

19:46

So, we made one thing a little nicer, but made another thing much worse.

19:50

Now theoretically we could potentially introduce a reducer operator that hides some of these details from us. For example, what if it was possible to say, at a very high level, that we want to synchronize the stats state in the parent with the stats state in each tab feature. Maybe we could even make some fancy variadic function that would hide away these details. .share(\.stats, with: \.profile.stats, \.counter.stats) // .onChange(of: \.counter.stats) { _, stats in // Reduce { state, _ in state.stats = stats; return .none } // } // .onChange(of: \.profile.stats) { _, stats in // Reduce { state, _ in state.stats = stats; return .none } // } // .onChange(of: \.stats) { _, stats in // Reduce { state, _ in // state.counter.stats = stats // state.profile.stats = stats // return .none // } // }

20:29

That does seem quite a bit simpler, but we aren’t going to spend the time to do this.

20:34

Also, even if we did implement that helper, it doesn’t change the fact that the test suite for these features has also gotten much, much worse. Every test is now failing because we need to further describe how the parent’s stats data changes with each action. For example, in the first test we need to update like so: await store.send(.selectTab(.profile)) { $0.currentTab = .profile $0.counter.stats.increment() $0.profile.stats.increment() $0.stats.increment() } await store.send(.selectTab(.counter)) { $0.currentTab = .counter $0.counter.stats.increment() $0.profile.stats.increment() $0.stats.increment() } And in the second test we need to update like so: await store.send(.counter(.incrementButtonTapped)) { $0.counter.stats.increment() $0.profile.stats.increment() $0.stats.increment() } await store.send(.counter(.decrementButtonTapped)) { $0.counter.stats.decrement() $0.profile.stats.decrement() $0.stats.decrement() } await store.send(.profile(.resetStatsButtonTapped)) { $0.counter.stats = Stats() $0.profile.stats = Stats() $0.stats = Stats() }

21:14

Now the test suite passes, but we are having to do a lot of extra work to update all the copies of “shared” state. And that’s because the state isn’t truly “shared.” We are simply maintaining many copies and trying our hardest to keep all of those copies in sync.

21:28

So, what if we tried making the stats in the parent SharedState feature be the one “true” stats value, and project it into counter and profile states via computed properties? This means we trade the counter stored property for a computed property, and then we can pass along the stats data: var counter: CounterTab.State { get { CounterTab.State(stats: self.stats) } set { self.stats = newValue.stats } }

22:12

And similarly for the profile state: var profile: ProfileTab.State { get { ProfileTab.State(stats: self.stats) } set { self.stats = newValue.stats } }

22:26

And because there is technically now just one single stats value that is projected into each of the tab features, we can get rid of all the synchronization logic: // .onChange(of: \.counter.stats) { _, stats in // Reduce { state, _ in state.stats = stats; return .none } // } // .onChange(of: \.profile.stats) { _, stats in // Reduce { state, _ in state.stats = stats; return .none } // } // .onChange(of: \.stats) { _, stats in // Reduce { state, _ in // state.counter.stats = stats // state.profile.stats = stats // return .none // } // }

22:37

This actually seems to work. We can run the preview and see that incrementing, and switching tabs, and reset all work just as they did before.

22:52

Let’s run the test suite just to be sure. We of course get some failures, but that’s to be expected because we no longer need to mutate every stats property in the features. We only need to mutate the one. So if we fix those…

23:28

…all but one test passes, and it’s a test that has always passed. We’ve never seen this one fail.

23:34

And this test failure is a real failure, showing a real bug in our application. It is no longer true that when tapping the prime number button that an alert shows. And we can go back to the preview to confirm that.

23:55

So, what gives?

23:56

Well, there is a serious problem with the counter computed property. It needs to create a whole new CounterTab.State from scratch, but the parent feature doesn’t hold onto all the state necessary to do that. It only holds onto the stats state, but what about everything else the counter tab may need, such as the alert data: var counter: CounterTab.State { get { CounterTab.State( alert: <#AlertState<CounterTab.Action.Alert>?#>, stats: self.stats ) } set { self.stats = newValue.stats } }

24:25

The alert has to live somewhere , and since we aren’t representing it anywhere it is just always nil . It never gets populated.

24:31

And so there would be more work we need to do here to make this computed property approach work, but it’s just more work and difficult to get right. So let’s back out of those changes…

24:42

We are seeing over and over again that “shared state” seems to be quite different from regular feature state. We are trying to use value types to model the state, but that is forcing us to implement all kinds of synchronizing logic that is incredibly easy to get wrong. Shared state using dependencies

24:53

So, those are the problems we faced when trying to introduce “shared state” into Composable Architecture applications. Sharing state just cannot be easily done with value types, yet the library strongly prefers that we use value types for pretty much everything.

25:06

And our little “shared state” case study and the SyncUps app tried their best to show off one way of sharing value data with multiple features, but there is just a lot to not like about it, and honestly we do not really recommend that technique for complex applications. Brandon

25:21

And this is why in other discussion outlets, such as in the Point-Free Slack community and the GitHub discussions we suggested an alternative, but we never codified it in a case study because although the alternative improves on a number of things, it also has some drawbacks.

25:37

The alternative is to leverage the dependency system of the Composable Architecture in order to ubiquitously share state with many features. That sounds really reasonable at first. After all, the dependency system is all about propagating dependencies deeply into an application without needing to literally pass dependencies through every intermediate layer.

25:57

Let’s take a quick look at what that looks like in practice, and then see what the pros and cons of it are.

26:04

Let’s demonstrate this technique by refactoring the shared state case study to use a dependency. The idea is to design a dependency that is capable of getting and setting some globally available stats data. We’ll call this dependency StatsClient : struct StatsClient { }

26:28

It’ll have an endpoint for synchronously getting the current stats: struct StatsClient { var get: () -> Stats }

26:34

And we’ll need an endpoint for setting the stats: struct StatsClient { var get: () -> Stats var set: (Stats) -> Void }

26:38

But because we need dependencies to be sendable we will mark these closures as @Sendable : struct StatsClient { var get: @Sendable () -> Stats var set: @Sendable (Stats) -> Void }

26:46

Next we conform this type to the DependencyKey protocol as a first step to register, and to do that we need to provide a live value: extension StatsClient: DependencyKey { static var liveValue: StatsClient { StatsClient( get: <#() -> Stats#>, set: <#(Stats) -> Void#> ) } }

27:03

We can implement these endpoints by creating a mutable stats variable outside the closures, and then accessing it inside the closures: static var liveValue: StatsClient { var stats = Stats() return StatsClient( get: { stats }, set: { stats = $0 } ) }

27:19

However, this does not compile because you are not allowed to access var s from inside @Sendable closures: Reference to captured var ‘stats’ in concurrently-executing code

27:28

So, we need to wrap the Stats value in a sendable-safe type, and that’s exactly what our LockIsolated type can accomplish: static var liveValue: StatsClient { let stats = LockIsolated(Stats()) return StatsClient( get: { stats.value }, set: { stats.setValue($0) } ) }

27:53

That’s all it takes to register this dependency with the library.

27:57

There is a second step that you’ve probably taken in the past that adds a computed property to DependencyValues : extension DependencyValues { var stats: StatsClient { get { self[StatsClient.self] } set { self[StatsClient.self] = newValue } } }

28:19

…which allows you to then use the @Dependency property wrapper with this key path: @Dependency(\.stats) var stats

28:41

But, we’ve recently made an update to our Dependencies library that even makes this step optional. You can now just use the type of dependency as the key to find the dependency: @Dependency(StatsClient.self) var stats

29:04

It’s a little more verbose at the call site, but a little less boilerplate when registering. There are still times you may want to add a property to DependencyValues , especially when shipping a dependency as a library since it gives you discoverable autocomplete. But for application-specific dependencies this shorter syntax might be preferred.

29:41

We now have the dependency designed, and so we can start using it.

29:52

To start, in the parent SharedState feature we can stop holding onto stats : @ObservableState struct State: Equatable { … // var stats = Stats() }

30:00

And instead we can add a dependency on the StatsClient directly to the reducer: @Reducer struct SharedState { … @Dependency(StatsClient.self) var stats … }

30:09

Then in the reducer when we want to increment the stats we just have to do a little work to grab the current stats out of the dependency, increment it, and then set it back in the dependency: case let .selectTab(tab): var copy = stats.get() copy.increment() stats.set(copy) state.currentTab = tab return .none

30:33

And we can now comment out all the onChange synchronization because now the single dependency holds the true value of the stats: // .onChange(of: \.counter.stats) { _, stats in // Reduce { state, _ in state.stats = stats; return .none } // } // .onChange(of: \.profile.stats) { _, stats in // Reduce { state, _ in state.stats = stats; return .none } // } // .onChange(of: \.stats) { _, stats in // Reduce { state, _ in // state.counter.stats = stats // state.profile.stats = stats // return .none // } // }

30:45

Now the copy dance we had to do was a little gross, and I’m sure we are going to want to do things like this often, so maybe a modify helper on StatsClient would be better: struct StatsClient { var get: @Sendable () -> Stats var set: @Sendable (Stats) -> Void func modify(_ operation: (inout Stats) -> Void) { var stats = self.get() operation(&stats) self.set(stats) } }

31:11

And now we can simplify the reducer logic to just this: stats.modify { $0.increment() }

31:21

That takes care of the parent SharedState feature, but what about each of the features in the tabs? Their usage of the stats data was a little more complicated.

31:38

Let’s start with the CounterTab feature. Since the dependency holds onto the true stats value I would maybe expect that we can just get rid of that state from the feature: @Reducer struct CounterTab { @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? // var stats = Stats() } … } And then we can hold onto a dependency of stats instead: @Reducer struct CounterTab { … @Dependency(StatsClient.self) var stats … }

31:52

And in the reducer rather than trying to mutate the stats directly in state we will instead go through the dependency: case .decrementButtonTapped: stats.modify { $0.decrement() } return .none case .incrementButtonTapped: stats.modify { $0.increment() } return .none

32:06

And when needing to access state we need to go through the get() endpoint rather than accessing the stats directly on state: case .isPrimeButtonTapped: state.alert = AlertState { TextState( isPrime(stats.get().count) ? "👍 The number \(stats.get().count) is prime!" : "👎 The number \(stats.get().count) is not prime :(" ) } return .none

32:17

But this looks a little messy. Just as we improved updating stats with a modify method, we can also improve accessing fields in in the stats with dynamic member lookup: @dynamicMemberLookup struct StatsClient { … subscript<Value>(dynamicMember keyPath: KeyPath<Stats, Value>) -> Value { self.get()[keyPath: keyPath] } }

32:49

And now the get() noise goes away and we can just access fields directly in the stats client as if the properties were directly on the client itself: case .isPrimeButtonTapped: state.alert = AlertState { TextState( isPrime(stats.count) ? "👍 The number \(stats.count) is prime!" : "👎 The number \(stats.count) is not prime :(" ) } return .none

32:56

Everything is now compiling except for one thing in the view: Text("\(store.stats.count)") .monospacedDigit() …and this is a bummer.

33:13

We removed the stats from the feature’s state because we were hoping that we could just always go through the dependency when we need to access and update that data, but now we are seeing a situation where that isn’t true. The view needs access to stats data, and the view layer does not have access to dependencies.

33:19

So, unfortunately, we do actually need to hold onto stats in the feature’s state: @Reducer struct CounterTab { @ObservableState struct State: Equatable { … var stats = Stats() } … }

33:35

And then we will go back to just mutating this state directly: case .decrementButtonTapped: state.stats.decrement() return .none case .incrementButtonTapped: state.stats.increment() return .none case .isPrimeButtonTapped: state.alert = AlertState { TextState( isPrime(state.stats.count) ? "👍 The number \(state.stats.count) is prime!" : "👎 The number \(state.stats.count) is not prime :(" ) } return .none

33:49

And we have to synchronize any changes to this local piece of state to the dependency: .onChange(of: \.stats) { _, newStats in let _ = stats.set(newStats) }

34:42

Now everything is compiling, but we have to do similar changes in the ProfileTab feature.

34:48

That means adding the stats dependency to the feature: @Reducer struct ProfileTab { … @Dependency(StatsClient.self) var stats … }

34:53

And synchronizing the state back to the dependency: var body: some Reducer<State, Action> { Reduce { state, action in … } .onChange(of: \.stats) { _, newStats in let _ = stats.set(newStats) } }

35:13

And with all of that accomplished I would hope it works but sadly it does not. If we increment in the first tab and switch to the second, we will see that the stats did not update.

35:28

The reason this is happening is because there is nothing to seed the initial stats state to each feature when it appears. It’s unfortunate, but we have a bit more synchronization logic to capture here.

35:41

We can add an onAppear action to both tab features: case onAppear

35:47

That is responsible for setting up the initial stats: case .onAppear: state.stats = stats.get() return .none

36:01

And then we can send that action when the view appears: .onAppear { store.send(.onAppear) }

36:37

Now, finally, the feature works how we expect.

36:58

So, it’s still a lot of work to share state through a dependency, but there is one thing to like about the approach over the previous approaches we’ve seen. Each feature is solely responsible for its own synchronization logic. That is a lot easier to get right. We no longer need to have the parent feature know all the various features that want access to the shared stats and employ a byzantine system of onChange operators to keep everything in sync.

37:53

But, what do the tests look like?

37:54

Well, if we run the test suite we will see a bunch of failures because the behavior has substantially changed. When the tab changes, the stats is incremented, but this happens in the dependency, and to observe that change, we need the tab to appear: await store.send(.selectTab(.profile)) { $0.currentTab = .profile } await store.send(.profile(.onAppear)) { $0.profile.stats.increment() }

38:30

And similarly when switching back to the counter tab, except now the counter’s stats have actually gone up twice since we switched the tab twice: await store.send(.selectTab(.counter)) { $0.currentTab = .counter } await store.send(.counter(.onAppear)) { $0.counter.stats.increment() $0.counter.stats.increment() }

39:01

But if we run that we get a test failure because we are using a dependency in tests without overriding it: @Dependency(StatsClient.self) has no test implementation, but was accessed from a test context: …

39:11

This is a good test failure to have because it forces us to explicitly assert on what dependencies are used in which user flows, but also it feels a little strange to have the failure for something like shared state.

39:28

We can override it easily enough: let store = TestStore(initialState: SharedState.State()) { SharedState() } withDependencies: { $0[StatsClient.self] = .liveValue }

39:55

And now the test suite passes. Or we could have just provided a testValue that defaults to the liveValue : static let testValue = liveValue

40:10

And now the test would pass without explicitly overriding in the test, and that feels like a more reasonable default for something like shared state.

40:21

I’m not going to even finish fixing the other tests, because honestly it’s a bit of a pain. And this still is not 100% correct. It’s correct enough for this simple demo, but if there was some other, outside process that was capable of changing stats while one of the tabs is visible, then we would want our view to update with the freshest stats.

40:43

That would mean our StatsClient would need to expose an additional endpoint that gives you an async sequence that features could subscribe to to get the freshest data: @dynamicMemberLookup struct StatsClient { var get: @Sendable () -> Stats var set: @Sendable (Stats) -> Void var stream: @Sendable () -> AsyncStream<Stats> … }

41:05

This can take a lot of work to get right, and it makes the features even more complicated. So we aren’t going to show this off in its fullness right now.

41:12

But, there is a place we have demonstrated this technique in its fullness, and that is in our isowords application, which is an open source word game that is wrapped around a 3D cube, and built entirely with the Composable Architecture and SwiftUI. We created a pull request about one and a half years ago to implement this, which I have open, and while there is a lot to like about it, there’s still some thorny edge cases.

41:38

The “shared state” we applied this technique to was the settings of the application. The settings contains various values, such as if haptics or sound effects are enabled, and then we need access to those settings in many places throughout the application.

41:56

Prior to this pull request we held the Settings.State in the HomeFeature : public var settings: Settings.State

42:13

And then we had to contort ourselves in all types of strange ways to project these settings into the various features that needed the settings. For example, in the root app feature reducer we derived the currentGame state from multiple pieces of state just so that we could shoehorn in the settings: public var currentGame: GameFeature.State { get { GameFeature.State(game: self.game, settings: self.home.settings) } set { let oldValue = self let isGameLoaded = newValue.game?.isGameLoaded == .some(true) || oldValue.game?.isGameLoaded == .some(true) let activeGames = newValue.game?.activeGames.isEmpty == .some(false) ? newValue.game?.activeGames : oldValue.game?.activeGames self.game = newValue.game self.game?.activeGames = activeGames ?? .init() self.game?.isGameLoaded = isGameLoaded self.home.settings = newValue.settings } }

42:45

This is similar to what we tried doing a moment ago in the shared state case study, and it’s a just a huge pain. There’s is a lot that we could get wrong here. And we had to repeat this pattern in a few places.

43:00

So, that’s why we refactored this code to model settings as a dependency, called the UserSettingsClient : @dynamicMemberLookup public struct UserSettingsClient { public var get: @Sendable () -> UserSettings public var set: @Sendable (UserSettings) async -> Void public var stream: @Sendable () -> AsyncStream<UserSettings> … }

43:08

It’s quite similar to our StatsClient , and it even provides the stream endpoint for getting a sequence of updated settings if anyone changes the settings.

43:17

This client seems pretty nice at first, but it’s also a lot of code to maintain. We employ some of the same tricks we did for StatsClient , such as dynamic member look up and the modify function, and we do extra work to support the stream endpoint. We are going to have to repeat a lot of this every time we want to introduce some shared state to our applications and that seems like a pain.

43:46

But, beyond that, it does actually allow us to remove the settings property from the home feature, and that massively simplified how we construct the state of child features that need settings state. In fact, that complicated currentGame computed property we showed a moment ago was completely deleted…

44:10

And if we search the project for “usersettings.” we will see all the places we make use of the dependency.

44:17

For example, right when the app launches we access the userSettings dependency to set the current volume in the audio player and to update the interface style: group.addTask { await self.audioPlayer.setGlobalVolumeForSoundEffects( userSettings.soundEffectsVolume ) await self.audioPlayer.setGlobalVolumeForMusic( self.audioPlayer.secondaryAudioShouldBeSilencedHint() ? 0 : userSettings.musicVolume ) await self.setUserInterfaceStyle( userSettings.colorScheme.userInterfaceStyle ) }

44:28

We got immediate access to these settings by just doing something as simple as this: @Dependency(\.userSettings) var userSettings We can add that to any reducer and we immediately get read and write access to settings from within the body of the reducer.

44:44

However, strangely, the @Dependency is added to the reducer , not to the reducer’s state . This means that while we do have have access to the settings in the reducer, crucially the view does not have access at all.

44:59

And there is a view that really needs access to that data, and that’s in the view that renders the 3D cube for the game. This view is handled by SceneKit and not done in pure SwiftUI. It needs to access to some settings data so that it can do things like customize the shading on the cube, disable the motion manager, and toggle some debug info on the screen.

45:20

But since userSettings doesn’t actually live in state, no view has access to settings. And this is why the UserSettingsClient exposes a stream endpoint. It allows the reducer to listen for changes to the settings so that if anyone anywhere updates settings, it will emit and allow us to react to those changes.

45:36

If we search the project for userSettings.stream we will find the one place we use this endpoint, which is when the game feature appears: group.addTask { for await userSettings in self.userSettings.stream() { await send(.userSettingsUpdated(userSettings)) } }

45:47

We just subscribe to the stream and send any updated settings back into the system so that we can react to it.

45:54

And then we use that action in order to copy over the settings we care about into state so that the view can use it: case let .userSettingsUpdated(userSettings): state.enableGyroMotion = userSettings.enableGyroMotion state.isAnimationReduced = userSettings.enableReducedAnimation return .none

46:02

So, while stuffing settings into the dependency system did seem to fix a lot of problems, we still have some of the old problems. Because dependencies are typically used in reducers, and not in state, we don’t actually get access to any of that data from a view. Next time: The solution

46:20

We have now seen two approaches to sharing state in a Composable Architecture application:

46:24

You can model everything as value types directly in your features’ states, but then you are left with the difficult problem of keeping a bunch of disconnected values in sync. This most likely means a liberal dose of onChange reducers and/or delegate actions, and it will be quite easy to get wrong. And also your tests will suffer since you will need to assert on changes to all of those copies of state. Stephen

46:45

Or you can model your shared state as a dependency, but it takes a bit of boilerplate to accomplish, and worst of all the state isn’t actually held in the feature’s State struct. It’s only held in the reducer, and so we have to introduce even more boilerplate to listen for changes in the shared state and play them to the feature’s state, just so that the view can get access to that state.

47:04

It feels like we’re playing whack-a-mole with shared state. Each attempt solves a problem and then a whole new problem pops up. Brandon

47:10

However, there is something pretty interesting about the dependency style of shared state that sets it apart from the synchronization style. By using a dependency for our shared state we have in some sense introduced a reference type into our feature. After all, structs with closures are basically a crude approximation of reference types, which is what our UserSettingsClient type is. This means it behaves like a class for all intents and purposes.

47:37

In general, dependencies are very reference-y, and we are seeing the pros and cons of that fact very directly in isowords. The pros are that state can be updated from any place in the entire app and every other part of the app can see those changes immediately. That’s exactly what we want from shared settings state. Stephen

47:53

But the cons are that there is a lot of setup needed to make the dependency work in state, and it made testing a pain. We had an extra onAppear and userSettingsUpdated action to deal with in order to communicate between the dependency and the feature.

48:07

So, whether we like it or not, the share state dependency has introduced a reference type in our feature, and if we are OK with that, then my next question would be: could we have just crammed a reference type directly into the feature’s state from the beginning? Wouldn’t that give us the state sharing capabilities without any of the boilerplate of a dependency?

48:23

Well, let’s try it out…next time! Downloads Sample code 0268-shared-state-pt1 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .