EP 269 · Shared State · Mar 4, 2024 ·Members

Video #269: Shared State: The Solution, Part 1

smart_display

Loading stream…

Video #269: Shared State: The Solution, Part 1

Episode: Video #269 Date: Mar 4, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep269-shared-state-the-solution-part-1

Episode thumbnail

Description

The various approaches of sharing state in the Composable Architecture are mixed bag of trade offs and problems. Is there a better way? We’ll take a controversial approach: we will introduce a reference type into our state, typically a value type, and see what happens, and take it for a spin in an all new, flow-based case study.

Video

Cloudflare Stream video ID: b72da2e2e7b12983b86c275646620fe9 Local file: video_269_shared-state-the-solution-part-1.mp4 *(download with --video 269)*

Transcript

0:05

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

0:09

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

0:31

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.

0:49

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

0:56

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.

1:22

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

1:38

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.

1:53

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?

2:09

Well, let’s try it out. Shared state as a reference?

2:12

To explore this we are going to look at the shared state case study again, but we are going to undo all the work we did last episode and just go back to exactly how the case study is in the last release of the library:

2:30

What if we turned the Stats type into a class instead of a struct: class Stats: Equatable { … }

2:35

That would allow each of the features to hold onto the same reference of the stats, and they can freely read from and write to the stats. But since they are all the same reference, there is no need to copy around values from one place to another. It just happens immediately.

2:48

But of course this isn’t compiling because most structs can’t just immediately be changed into classes. First of all we need to remove the mutating keyword from the methods: func increment() { count += 1 numberOfCounts += 1 maxCount = max(maxCount, count) } func decrement() { count -= 1 numberOfCounts += 1 minCount = min(minCount, count) }

2:59

Next the reset method cannot be implemented in this super simple style: func reset() { self = Self() }

3:04

We have to do the more verbose, and more fragile, resetting of each field: func reset() { self.count = 0 self.maxCount = 0 self.minCount = 0 self.numberOfCounts = 0 }

3:09

That’s a bummer because if we add new fields in the future we have to make sure to update this code.

3:15

And next we are faced with the uncomfortable question of how to make Stats conform to Equatable . Since the class just holds onto some data and doesn’t have any behavior, maybe we can just do a fieldwise comparison of the two objects: static func == (lhs: Stats, rhs: Stats) -> Bool { lhs.count == rhs.count && lhs.maxCount == rhs.maxCount && lhs.minCount == rhs.minCount && lhs.numberOfCounts == rhs.numberOfCounts }

3:37

This is more verbose, and it does mean that if in the future new fields are added to Stats we are going to have to remember to update this function too.

3:45

We could have also gone with simple identity equality: static func == (lhs: Stats, rhs: Stats) -> Bool { lhs === rhs }

3:54

It’s hard to know which is correct, so we will just go with the field wise comparison for now and reassess as we get more comfortable with the idea of having a reference type in our features.

4:03

With that done the project is now compiling, but we still have more work to do. We still have 3 completely distinct Stats objects in our features: one in the SharedState feature, one in the CounterTab feature, and one in the ProfileTab feature. We’d like them all to hold onto a single reference to a Stats object.

4:29

To do that we will drop the default for the stats field from the CounterTab feature and instead force it to be passed in from the outside: @Reducer struct CounterTab { @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? var stats: Stats } … }

4:37

And we will do the same for the profile feature…

4:42

And then in the parent SharedState feature we will drop the defaults for counter and profile , and instead provide an explicit initializer that requires passing in a single Stats object from the parent so that we can then hand it off to each tab feature: @ObservableState struct State: Equatable { var currentTab = Tab.counter var counter: CounterTab.State var profile: ProfileTab.State init( currentTab: Tab = Tab.counter stats: Stats = Stats() ) { self.currentTab = currentTab self.counter = CounterTab.State(stats: stats) self.profile = ProfileTab.State(stats: stats) } }

5:19

Let’s also bring back the functionality where the SharedState feature also held onto some stats so that it could increment when the tab changed: @ObservableState struct State: Equatable { var currentTab = Tab.counter var counter: CounterTab.State var profile: ProfileTab.State var stats: Stats init( currentTab: Tab = Tab.counter, stats: Stats = Stats() ) { self.currentTab = currentTab self.stats = stats self.counter = CounterTab.State(stats: stats) self.profile = ProfileTab.State(stats: stats) } } And we’ll add that logic back to the reducer: case let .selectTab(tab): state.currentTab = tab state.stats.increment() return .none

5:40

Now everything is back to compiling, but we have 3 different features all using the same Stats object, which hopefully means all features can read from and write to that state at will without needing complex synchronization logic throughout our features or leveraging dependencies.

5:54

For example, when the tab changes in the parent feature we can reach right into the stats reference and mutate it directly: case let .selectTab(tab): state.stats.increment() state.currentTab = tab return .none

6:01

And because everything is driven off this single reference we should be able to delete the onChange reducer operators…

6:10

Everything is compiling, and this is by far the simplest way to use shared data that we have seen so far. No synchronization logic, no dependencies. Just simple features holding onto the state they need to do their job, and sharing references to state that they all need access to.

6:26

It would be great if this just worked, but unfortunately it does not.

6:30

If we run the preview and try incrementing the counter we will see that nothing happens. The view is not updating with the state changes.

6:39

However, if we tap the “Is this prime?” button, then somehow the count does update and the alert even shows. What is going on?

6:48

Well, what’s going on is that we have stuck a reference type into our state, and historically reference types have not played nicely with how SwiftUI invalidates views and re-renders. At its core, SwiftUI wants to listen for model properties changing so that it can invalidate and schedule a re-render, causing the body of the view to be re-computed with the freshest model data.

7:06

Up until about 5 months ago that was done by using the ObservableObject protocol with the @Published property wrapper: class CounterModel: ObservableObject { @Published var count = 0 }

7:17

If you mutate a @Published property it will tell the enclosing observable object that something was changed, and that would in turn tell whatever view was holding onto the object that it needs to re-render. This whole process’ success hinges on the fact that mutates to value types trigger a willSet callback, allowing the model to let SwiftUI know something is about to change.

7:35

However, mutations to reference types do not go through the usual willSet and didSet callbacks in types. That only works for value types, and so reference types just did not work with the @Published property wrapper.

7:46

But then all of that changed about 5 months ago when the @Observable macro was released, which finally made it possible to easily observe changes in reference types. The macro instruments every property of a class so that any mutation to a field will notify the view that it needs to invalidate, and it even has some smarts baked in so that only the properties the view accesses can cause view invalidation.

8:07

And it’s precisely this observation machinery that came with Swift 5.9 and iOS 17 that is going to allow us to use reference in our Composable Architecture features, while making it still play nicely with view invalidation.

8:19

Let’s see how that works.

8:21

First let’s mark the Stats class as being observable by using the @Observable macro: @Observable class Stats: Equatable { … }

8:24

And second… well, there is no second. That is all it takes to make this work.

8:30

We can run the preview and see that everything works exactly as it did before.

8:49

And if we could not yet target iOS 17 and later we can always use the back-ported tools that we have provided by marking the class with the @Perceptible macro: @Perceptible class Stats: Equatable { … }

8:59

This would also work, but this macro can be used in older Apple platforms, going all the way back to iOS 13.

9:04

But, since the case studies project targets iOS 17 this macro is actually deprecated, and so let’s switch it back to @Observable : @Observable class Stats: Equatable { … }

9:11

It’s pretty amazing to see how simple it is to share state between features when you are allowed to use reference types. Just wrap your data in a class, instantiate one single object from that class, pass that object to whatever features you want, and presto! All features now share the same state, the view has access to all state, and the view will automatically re-render when the state changes.

9:31

But, I’m sure some of our viewers are getting a little weirded out that we have put a reference type in our feature’s state. That seems to go against the grain of the Composable Architecture, which prefers to model our domains as value types.

9:42

This reference type feels like it exists outside the feature in some weird way. It is capable of doing things that regular state in a Composable Architecture feature cannot do:

9:51

Its state can change without sending an action into the system. For example, in the initializer of SharedState we can technically fire off some asynchronous work and increment the stats: @ObservableState struct State: Equatable { var currentTab = Tab.counter var counter: CounterTab.State var profile: ProfileTab.State var stats = Stats() init( currentTab: Tab = Tab.counter ) { self.currentTab = currentTab let stats = Stats() self.stats = stats self.counter = CounterTab.State(stats: stats) self.profile = ProfileTab.State(stats: stats) DispatchQueue.main.asyncAfter(deadline: .now() + 1) { stats.increment() } } } Now when we run the preview we will see that after one second the counter updates to 1, and that all happened without sending a single action into the system. That’s really weird, and goes against everything we have come to expect from features built in the Composable Architecture.

10:20

The reference type can even perform side effects without going through the official effect system of the reducer. For example, after incrementing we could perform a network request and then when we get a response we could decrement: func increment() { count += 1 numberOfCounts += 1 maxCount = max(maxCount, count) URLSession.shared .dataTask(with: URLRequest(url: URL(string: "http://www.google.com")!)) { _, _, _ in self.decrement() } .resume() } This too is very strange.

10:37

What we are seeing here is that holding onto a reference type in state has created a parallel world of logic and behavior that runs alongside our regular Composable Architecture feature. It is no longer true that we only have a single place to hold state, a single place to mutate state, and a single place to execute effects and feed their data back into the system.

10:54

And so reference types are clearly strange, and we haven’t even discussed testing yet. Reference types are notoriously difficult to test because they are an amalgamation of data and behavior, and because they can’t be copied, but we will get into testing a bit later. Improving the ergonomics of shared state

11:06

You may have heard the phrase “single source of truth” thrown around, and in the Composable Architecture that always seemed like an easy thing to explain because the Store at the root of the app was the single source of truth. The state it held powered the entire application.

11:20

But now it seems like we have multiple sources of truth. There’s the data inside the features, such as the current tab and the alert being shown. And the there’s the data held in this reference type, which really exists in parallel to the feature state, but isn’t really “contained” inside the feature state. Brandon

11:37

And we are here to say that this actually isn’t such a bad thing. In fact, all of these supposed downsides of reference types was actually already true of the dependency style of sharing data that we demonstrated a moment ago. That data also existed “outside” of the main state, and it could be changed at anytime, even if an action wasn’t sent into the system.

11:57

And in fact, all it’s really pointing out is that there really is no such that as a “single source of truth”! It was a fallacy from the beginning. In reality, there are two sources of truth. There is the “source of truth” that defines the state your feature is in, such as if a button is enabled or not, or if a child feature is being presented, and then there’s the “source of truth” of data that lives externally, whether that be in a database, on the file system, or an external server. Stephen

12:23

It’s this latter “source of truth” that gets at the core of what shared state is. It’s a form of data that our applications need, but its true essence lives outside of our applications. And that’s OK. That’s just life.

12:34

So we don’t think that the reference style of sharing state is any stranger to use in the Composable Architecture than the dependency style, but it’s certainly a lot simpler! There’s no synchronization tricks, we can just pass around references to share state, and thanks to the magic of Swift’s observation tools even the view can update when the reference is mutated. Brandon

12:53

But, reference types in state are not without their drawbacks, as we’ve seen. We faced the problem of needing to provide an Equatable conformance for a class, and we lost some of the niceties of using value types, such as being able to reset the stats by just constructing a whole new value. And of course reference types are going to completely blow up our chances of performing exhaustive testing.

13:14

Let’s first focus our attention on the ergonomics of using reference types in the Composable Architecture, and we’ll worry about testing later.

13:24

We gave up a lot of the power of value types by making the Stats type into a class, but I think we can recover a lot of the benefits of value types even when using shared state.

13:33

Rather than making our domain type into a class, what if we had a single, generic type class that acted as the reference that could be shared, and it could hold onto a value type to share: final class Shared<Value> { var value: Value init(_ value: Value) { self.value = value } }

13:59

This could be library code provided by the Composable Architecture, and hopefully it will allow us to keep using value types in our domain, and we can use this single class type to wrap our values in something shareable.

14:12

In fact, let’s optimistically change our Stats type back to a struct, and simplify its implementation to take advantage of the features that only value types have: 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() } }

14:47

And with those changes, we have compiler errors where we mutate stats out-of-band, in a dispatch queue and URL response: it is forbidden to do so with a value type, which is one of the reasons we love them.

16:08

Then hopefully we can replace our uses of Stats in our features with a Shared<Stats> . For example, in the CounterTab feature we could do the following: @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? var stats: Shared<Stats> } We’ve already got a few compiler errors, and some are easy to fix.

16:20

First we have an error letting us know that SharedState.State is no longer equatable, and that’s because currently Shared is not equatable. But we can make it conditionally equatable based on the value it wraps: extension Shared: Equatable where Value: Equatable { static func == (lhs: Shared, rhs: Shared) -> Bool { lhs.value == rhs.value } }

16:50

Next we have some errors because we can no longer call methods directly on the stats property, we instead have to go through the value property: case .decrementButtonTapped: state.stats.value.decrement() return .none case .incrementButtonTapped: state.stats.value.increment() return .none And then lower down in the reducer and view we have some places where we are accessing properties of the states, but now we have to go through the shared layer first.

17:10

With that the entire CounterTab feature is compiling, but the SharedState feature that initializes it must now provide shared stats to the counter. let sharedStats = Shared(stats) self.counter = CounterTab.State(stats: sharedStats)

17:26

And with that things are compiling again, but I think we can improve the ergonomics a bit. We have added a .value layer throughout the counter feature to access stats state, but we can still support direct access syntax, thanks to dynamic member lookup: @dynamicMemberLookup final class Shared<Value> { … subscript<Member>(dynamicMember keyPath: KeyPath<Value, Member>) -> Member { self.value[keyPath: keyPath] } }

17:59

Which allows us to drop .value wherever we are accessing a property.

18:32

We can hop down to the ProfileTab feature and apply similar changes. We will upgrade the stats property to be shared: @ObservableState struct State: Equatable { var stats: Shared<Stats> }

18:39

And we need to update the reducer to go through the value property: case .resetStatsButtonTapped: state.stats.value.reset() return .none And nothing needs to change in the view, thanks to dynamic member lookup.

18:52

And then in the parent feature we will pass the shared stats down to the profile, and now each tab holds onto the same reference: let sharedStats = Shared(Stats()) self.counter = CounterTab.State(stats: sharedStats) self.profile = ProfileTab.State(stats: sharedStats)

19:12

While we’re here we can upgrade the parent feature to work with this same reference. @ObservableState struct State: Equatable { var currentTab = Tab.counter var counter: CounterTab.State var profile: ProfileTab.State var stats: Shared<Stats> init( currentTab: Tab = Tab.counter, stats sharedStats: Shared<Stats> = Shared(Stats()) ) { self.currentTab = currentTab self.counter = CounterTab.State(stats: sharedStats) self.profile = ProfileTab.State(stats: sharedStats) self.stats = sharedStats } }

19:37

Now almost everything is compiling, we just have a few things to fix in the parent feature. First in the SharedState reducer we need to go through the shared value to increment: case let .selectTab(tab): state.stats.value.increment() state.currentTab = tab return .none

19:44

Everything now compiles, and I would hope the feature works just as before, but unfortunately it does not. Tapping any of the buttons doesn’t do anything.

20:12

This is just because the Shared class is not observed. Remember how we had to previously make the Stats class observable? Well, we must do the same on this generic type: @Observable @dynamicMemberLookup final class Shared<Value> { … }

20:23

And now everything works as we expect.

20:30

And this is starting to seem pretty great. We get to use a value type for the state we want to share, which means we can take advantage of all of the benefits of value types such as synthesized equatability, and we get to use a single class as the mechanism for sharing the state throughout the app.

20:45

But, I think we can still do better. It was a bummer that we had to reach through .value in a bunch of places in order to get to the underlying Stats data. There is a tool in Swift that can make this much better, and it’s property wrappers. Property wrappers allow you to decorate a property with some additional information, which allows you to use the property in a normal fashion without ever thinking about those decorations.

21:12

So, let’s make Shared into a property wrapper: @Observable @dynamicMemberLookup @propertyWrapper final class Shared<Value> { … }

21:13

And in order to satisfy the requirements of a property wrapper we need to rename value to wrappedValue : @Observable @dynamicMemberLookup @propertyWrapper final class Shared<Value> { var wrappedValue: Value init(_ value: Value) { self.wrappedValue = value } subscript<Member>(dynamicMember keyPath: KeyPath<Value, Member>) -> Member { self.wrappedValue[keyPath: keyPath] } } extension Shared: Equatable where Value: Equatable { static func == (lhs: Shared, rhs: Shared) -> Bool { lhs.wrappedValue == rhs.wrappedValue } }

21:53

With that done things are still compiling, but we now have the ability to simplify some things.

22:00

For example, the CounterTab feature can start using the @Shared property wrapper: @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? @Shared var stats: Stats }

22:12

But now we can flex some of the property wrapper muscles, because down in the reducer we get to mutate the stats property directly without going through the value property. case .decrementButtonTapped: state.stats.decrement() return .none case .incrementButtonTapped: state.stats.increment() return .none

22:20

Property wrappers do not play nicely with macros like @Observable and @ObservableState , though, so we do have to ignore it from observation: @ObservableState struct State: Equatable { @Presents var alert: AlertState<Action.Alert>? @ObservationStateIgnored @Shared var stats: Stats } And this is OK to do because Shared encapsulates all of its own observation logic. Observing the actually stats property wasn’t really buying us anything.

22:52

We can do the same refactor in the ProfileTab feature: @ObservableState struct State: Equatable { @ObservationStateIgnored @Shared var stats: Stats }

23:06

And again we get to simplify how it is used in the reducer: case .resetStatsButtonTapped: state.stats.reset() return .none

23:20

In the SharedState feature we can start holding onto @Shared stats, rather than Shared<Stats> : @ObservableState struct State: Equatable { var currentTab = Tab.counter var counter: CounterTab.State var profile: ProfileTab.State @ObservationStateIgnored @Shared var stats: Stats … }

23:31

Now that stats is a property wrapper we need to update the initializer to set through the underscored property: self._stats = stats

23:40

And we can simplify the reducer: case let .selectTab(tab): state.stats.increment() state.currentTab = tab return .none

23:41

And now this compiles. And also dynamic member lookup isn’t even buying us anything anymore thanks to the property wrapper, so we can even get rid of that: // @dynamicMemberLookup … // subscript<Member>(dynamicMember keyPath: KeyPath<Value, Member>) -> Member { // self.wrappedValue[keyPath: keyPath] // }

23:51

Everything compiles, but it’s starting to get a little annoying that we have to explicitly ignore the @Shared fields. Let’s update the macro to do this for us automatically. We already have a few property wrappers and macros that the @ObservableStateMacro needs to be aware of, and so sounds like there is one more here.

24:15

If we hop over to ObservableStateMacro.swift we will see a list of identifiers we use to track the property wrappers and macros that need special attention. Let’s add a new one for the @Shared macro: static let sharedPropertyWrapperName = "Shared"

24:37

And then there are a few places we need to early out based on dealing with a property with this wrapper. For example, when expanding the @ObservationStateTracked accessor macro: if property.hasMacroApplication(ObservableStateMacro.ignoredMacroName) || property.hasMacroApplication(ObservableStateMacro.presentationStatePropertyWrapperName) || property.hasMacroApplication(ObservableStateMacro.presentsMacroName) || property.hasMacroApplication(ObservableStateMacro.sharedPropertyWrapperName) { return [] } …and peer macro: if property.hasMacroApplication(ObservableStateMacro.ignoredMacroName) || property.hasMacroApplication(ObservableStateMacro.presentationStatePropertyWrapperName) || property.hasMacroApplication(ObservableStateMacro.presentsMacroName) || property.hasMacroApplication(ObservableStateMacro.trackedMacroName) || property.hasMacroApplication(ObservableStateMacro.sharedPropertyWrapperName) { return [] } …and member macro: // dont apply to ignored properties or properties that are already flagged as tracked if property.hasMacroApplication(ObservableStateMacro.ignoredMacroName) || property.hasMacroApplication(ObservableStateMacro.trackedMacroName) || property.hasMacroApplication(ObservableStateMacro.sharedPropertyWrapperName) { return [] }

25:29

Now we can remove all of the @ObservationStateIgnored macros and everything compiles. Complex flow case study

25:48

We now have the basic idea of shared state in the Composable Architecture. If you want to share a piece of data amongst many features so that any feature can make changes to the state and so that all other features will instantly see those changes, then all you have to do is use the @Shared property wrapper and pass that reference around.

26:07

And sure it may seem strange to use a reference type in state, but honestly that is already what we were doing when we modeled shared state as a dependency. Dependencies are inherently reference-y. They can’t be copied, only shared, and any features was allowed to make changes to it indiscriminately. So, we are just embracing the “reference-iness” more fully by sticking a reference type directly into state. Stephen

26:30

Now of course reference types are going to cause all types of problems that we have to consider. For example, reference types are notoriously difficult to test and debug, and testing and debugging are some of the best parts of the library.

26:43

But before looking into any of that we want to show that with the little bit of infrastructure we have built we can already build very complex flows that use shared state. One of the most common use cases of shared state is complex, multi-step user flows. Onboarding and sign up are a good example of this, where the user needs to fill out a large form that is broken out across multiple screens. Each screen needs to be able to mutate the same piece of shared data so that at the end of the flow we can submit that data to the server.

27:11

We are going to build a new case study that demonstrates this kind user flow, and show how simple it is using shared state.

27:16

Let’s get started.

27:20

Let’s start by creating a new file for our case study:

27:30

And we will paste in some very basic scaffolding for the root feature, which will be a navigation stack since there are multiple steps we want to go through: import ComposableArchitecture import SwiftUI struct SignUpFlow: View { var body: some View { NavigationStack { Form { Section { Text("Welcome! Please sign up.") NavigationLink("Sign up") { } } } .navigationTitle("Sign up") } } } #Preview("Sign up") { SignUpFlow() }

27:44

We want to create a Composable Architecture feature for this view, and it will be responsible for managing the stack of features that can be pushed onto the screen, one for each step of the sign up flow, and it will perform some integration logic between the features.

27:57

To create a reducer we just need a new struct type with the @Reducer macro applied: @Reducer struct SignUpFeature { }

28:06

Already this is compiling.

28:08

This is a new feature of the @Reducer macro that we released a few weeks back and discussed on our livestream. But, if you didn’t catch that, then just know that this represents a reducer that has no logic or behavior. We can slowly add in the requirements to the reducer as we get more understanding of the domain.

28:24

Further, this root feature is going to manage a navigation stack, and we will want to model that in our reducer in order to tightly integrate all the destinations together, and so we will use the library’s navigation tools for this.

28:36

This means introducing a Path reducer that represents all the features that can be pushed onto the stack, which we can even nest inside the SignUpFeature type: @Reducer struct SignUpFeature { @Reducer struct Path { } }

28:45

And then we will integrate all the features that can be pushed onto the stack into this Path reducer by adding a case for each feature: @Reducer struct Path { @ObservableState enum State { // Case for each feature that can be pushed onto stack } }

29:07

We don’t currently have any features to put into the Path , so we will leave it empty, but soon we will start filling out this reducer.

29:14

And with the Path reducer defined we can now integrate it into the SignUpFeature . First we will define a State struct for holding onto some StackState : @ObservableState struct State { var path = StackState<Path.State>() }

29:35

And we’ll add a corresponding StackAction to the SignUpFeature : enum Action { case path(StackAction<Path.State, Path.Action>) }

29:43

And the final step to integrating the path into the SignUpFeature is to apply the forEach operator inside the body : var body: some ReducerOf<Self> { Reduce { state, action in // Core logic of the root feature return .none } .forEach(\.path, action: \.path) { Path() } }

30:23

With the basic scaffolding of our feature built we can introduce a store to the view that is powered by the SignUpFeature reducer, and we will hold onto the store in a @Bindable manner so that we can derive bindings to it: struct SignUpFlow: View { @Bindable var store = Store(initialState: SignUpFeature.State()) { SignUpFeature() } … }

30:47

And now we can derive a binding to the path to hand to the NavigationStack NavigationStack(path: $store.scope(state: \.path, action: \.path)) { … }

31:00

But this is a special initializer of NavigationStack that takes an additional trailing closure to describe all of the destination views that can be pushed onto the stack. The trailing closure is handed a store focused on the Path.State enum, which can be used to figure out which view to present, but right now we don’t have any such views: var body: some View { NavigationStack(path: $store.scope(state: \.path, action: \.path)) { … } destination: { store in switch store.state { } } }

31:29

Ideally we could switch on the empty enum right now so that in the future if a case is added we would immediately be notified with a compiler error. But unfortunately switches over empty enums do not work in view builder contexts.

31:41

One thing we can do to force it is return an explicit view from the closure so that we can continue doing the switch: } destination: { store -> EmptyView in switch store.state { } return EmptyView() }

31:54

This now compiles, and the compiler will loudly complain when we add new cases to Path.State letting us know there is more work to be done here.

32:04

So we’ve got some basics in place, and the first real piece of logic we want to implement is to tap the “Sign up” button and drill down to the first step of the sign up flow. Well, right now the “Sign up” button is a basic navigation link: NavigationLink("Sign up") { }

32:15

This form of navigation link is “fire-and-forget”, which means it isn’t driven by state at all. The user taps the link, and a drill-down animation occurs, but there is no representation of that fact in the state.

32:38

There’s a specific navigation link initializer that works with the Composable Architecture, and it is called NavigationLink(state:) : NavigationLink( "Sign up" state: <#P?#> )

32:37

You hand it a piece of state, and when the link is tapped that state will be appended to the path driving the navigation.

32:43

This means the data must come from Path.State : NavigationLink( "Sign up", state: SignUpFeature.Path.State.??? )

32:50

However, we still do not have any cases in Path.State and so there are no features to push onto the state. So temporarily let’s just put in nil for this state: NavigationLink( "Sign up", state: SignUpFeature.Path.State?.none )

33:02

And let’s now build the first step of the sign up flow so that we have something to push onto the stack. It will be called the “basics step”, and its purpose is to just collect the most basic, required data for sign up. We can start by getting an empty reducer into place. @Reducer struct BasicsFeature { }

33:14

Then we can start implementing the view and let that guide us how we should fill out the reducer.

33:18

The view will hold onto a store and render a basic form with some TextField s for collecting the email, password and password confirmation, and we will put a “Next” navigation link in the toolbar: struct BasicsStep: View { @Bindable var store: StoreOf<BasicsFeature> var body: some View { Form { Section { TextField("Email", text: <# [email protected] #>) } Section { SecureField("Password", text: <#••••••••#>) SecureField("Password confirmation", text: <#••••••••#>) } } .navigationTitle("Basics") .toolbar { ToolbarItem { NavigationLink( "Next", state: SignUpFeature.Path.State?.none ) } } } } #Preview("Basics") { NavigationStack { BasicsStep( store: Store(initialState: BasicsFeature.State()) { BasicsFeature() } ) } }

33:40

So, from the view we can clearly see that we need to add some state to our reducer in order to derive these bindings. In particular, strings for the email, password and password confirmation: @Reducer struct BasicsFeature { @ObservableState struct State { var email = "" var password = "" var passwordConfirmation = "" } }

33:59

And further we will want to derive a binding to each of these fields, and there are a few tools the library comes with to make this very easy while staying true to the core tenets of the library.

34:09

First we add an action handle bindings: enum Action: BindableAction { case binding(BindingAction<State>) }

34:23

And then we compose the BindingReducer into the body of the BasicsFeature : var body: some ReducerOf<Self> { BindingReducer() }

34:32

That’s all it takes in the reducer, but if in the future if we start performing validation logic or making API requests to check if the email address has already been registered, then that logic would be executed in this reducer. But we will keep things simple for right now.

34:48

With that done we can now fill in the bindings in the view: Form { Section { TextField("Email", text: $store.email) } Section { SecureField("Password", text: $store.password) SecureField("Password confirmation", text: $store.passwordConfirmation) } }

35:03

We now have a very basic feature that can be pushed onto the stack, and so let’s integrate it into the parent feature.

35:07

This is done by integrating the BasicsFeature reducer into the Path reducer. We do so by adding a case to the State enum for the basics feature state: @ObservableState enum State { case basics(BasicsFeature.State) }

35:20

And a case in the Action enum for the basics feature actions: enum Action { case basics(BasicsFeature.Action) }

35:27

And then we Scope down to those cases in order to run the BasicsFeature reducer inside the Path reducer: var body: some ReducerOf<Self> { Scope(state: \.basics, action: \.basics) { BasicsFeature() } }

35:47

That’s all it takes, but also it was a decent amount of work. And every time we add a new features that can be pushed onto the stack we will have to add a new case to each enum and then a scope to the body . But it will always look roughly the same.

36:01

But, if you caught our live stream from a few weeks ago, then you will know that we recently gave the @Reducer macro some new super powers. It is now capable of generating all of this boilerplate for you automatically. All you have to do is reframe the Path reducer as an enum with a case for each reducer you want to compose together: @Reducer enum Path { case basics(BasicsFeature) }

36:23

That’s all it takes. No need to manage two separate enums, one for state and one for action, and no need to implement the body of the reducer.

36:31

And even better, we can shorten the way we invoke forEach on this kind of reducer: var body: some ReducerOf<Self> { Reduce { state, action in // Core logic of the root feature return .none } .forEach(\.path, action: \.path) }

36:47

And now we helpfully have a compiler error down in the destination trailing closure letting us know there is a new case to handle in the switch.

37:17

And thanks to the new superpowers endowed to the @Reducer macro. When the @Reducer macro is applied to an enum like our Path type, it further enhances the type with a special property that allows destructuring a store for each case of the enum: } destination: { store in switch store.case { case let .basics(store): BasicsStep(store: store) } }

37:44

And now everything is compiling just as it was before, and we can update the NavigationLink at the root to link to the basics screen: NavigationLink( "Sign up", state: SignUpFeature.Path.State.basics(BasicsFeature.State()) )

37:53

And with that done we have our first drill-down. We can tap “Sign up” to go to the first step of sign up, and in that screen we can enter our email and password.

38:01

And we can even put a _printChanges on the reducer to confirm that state is changing when this link is tapped: @Bindable var store = Store(initialState: SignUpFeature.State()) { SignUpFeature()._printChanges() }

38:13

And this does indeed print out what we expect: received action: SignUpFeature.Action.path( .push( id: #0, state: .basics( BasicsFeature.State( _email: "", _password: "", _passwordConfirmation: "" ) ) ) ) SignUpFeature.State( _path: [ + #0: .basics( + BasicsFeature.State( + _email: "", + _password: "", + _passwordConfirmation: "" + ) + ) ] ) received action: SignUpFeature.Action.path( .popFrom(id: #0) ) SignUpFeature.State( _path: [ - #0: .basics( - BasicsFeature.State( - _email: "", - _password: "", - _passwordConfirmation: "" - ) - ) ] ) Next time: Adding another step to the flow

39:05

We now have some basic infrastructure in place for our sign up flow. We have a root navigation stack, and we have the ability to drill-down to the first screen of the flow. Pretty simple so far, but we did get to demo a fun new superpower of the @Reducer macro, which is that it generates all of the boilerplate necessary to model an enum of features, which is common for navigation stacks and tree-based navigation. Brandon

39:25

Let’s keep going. Let’s add the second step to the sign up flow, and this will make us come face-to-face with sharing data between features…next time! Downloads Sample code 0269-shared-state-pt2 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 .