Video #270: Shared State: The Solution, Part 2
Episode: Video #270 Date: Mar 11, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep270-shared-state-the-solution-part-2

Description
We finish building a complex, flow-based case study that leverages the new @Shared property wrapper. Along the way we will flex recently added superpowers of the library, and we will experience firsthand how simple this new model of shared state can be.
Video
Cloudflare Stream video ID: 526fd4ee1cd9c8d72187d80b3ad6c285 Local file: video_270_shared-state-the-solution-part-2.mp4 *(download with --video 270)*
Transcript
— 0: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
— 0: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. Adding another step to the flow
— 0:41
In the next step in the sign up flow, we will collect more personal information for the user, such as their first and last name and phone number. We can start with a simple reducer: @Reducer struct PersonalInfoFeature { @ObservableState struct State { var firstName = "" var lastName = "" var phoneNumber = "" } enum Action: BindableAction { case binding(BindingAction<State>) } var body: some ReducerOf<Self> { BindingReducer() } }
— 0:59
Again, this step could have much more complex logic, such as making an API request to send a text message to the phone number and having the user verify it before moving on to the next step. That logic can be entirely encapsulated into this one screen, but we aren’t going to do that right now.
— 1:20
Instead, we will move right onto the view, which like the basics view, is quite simple in that it just uses a form with some text fields for entering the data: struct PersonalInfoStep: View { @Bindable var store: StoreOf<PersonalInfoFeature> var body: some View { Form { Section { TextField("First name", text: $store.firstName) TextField("Last name", text: $store.lastName) TextField("Phone number", text: $store.phoneNumber) } } .navigationTitle("Personal info") .toolbar { ToolbarItem { NavigationLink( "Next", state: SignUpFeature.Path.State?.none ) } } } } #Preview("Personal info") { NavigationStack { PersonalInfoStep( store: Store(initialState: PersonalInfoFeature.State()) { PersonalInfoFeature() } ) } }
— 1:58
And with the basics of this new feature modeled we can hook things up to navigate to it. To do that we need to integrate this feature into the Path reducer, which thanks to the new superpowers of the @Reducer macro is as easy as this: @Reducer enum Path { case basics(BasicsFeature) case personalInfo(PersonalInfoFeature) }
— 2:18
That’s truly all it takes. We can even expand the macro to see all the code it is writing for us: @Reducer enum Path { case basics(BasicsFeature) case personalInfo(PersonalInfoFeature) @CasePathable @dynamicMemberLookup @ObservableState enum State: ComposableArchitecture.CaseReducerState { typealias StateReducer = Path case basics(BasicsFeature.State) case personalInfo(PersonalInfoFeature.State) } @CasePathable enum Action { case basics(BasicsFeature.Action) case personalInfo(PersonalInfoFeature.Action) } static var body: some ComposableArchitecture.Reducer<Self.State, Self.Action> { ComposableArchitecture.CombineReducers { ComposableArchitecture.Scope(state: \Self.State.Cases.basics, action: \Self.Action.Cases.basics) { BasicsFeature() } ComposableArchitecture.Scope(state: \Self.State.Cases.personalInfo, action: \Self.Action.Cases.personalInfo) { PersonalInfoFeature() } } } enum CaseScope { case basics(ComposableArchitecture.StoreOf<BasicsFeature>) case personalInfo(ComposableArchitecture.StoreOf<PersonalInfoFeature>) } static func scope(_ store: ComposableArchitecture.Store<Self.State, Self.Action>) -> CaseScope { switch store.state { case .basics: return .basics(store.scope(state: \.basics, action: \.basics)!) case .personalInfo: return .personalInfo(store.scope(state: \.personalInfo, action: \.personalInfo)!) } } }
— 2:55
That’s amazing. A simple 4 lines of code has expanded to over 40, and it makes our code safer and more expressive.
— 3:05
But, as soon as we do that we get a compiler error in the destination trailing closure letting us know there is a new case to deal with: } destination: { store in switch store.case { case let .basics(store): BasicsStep(store: store) case let .personalInfo(store): PersonalInfoStep(store: store) } }
— 3:22
And that’s all it takes. We can now link to the personal info screen from the basics screen. Remember that in the basics view we put a navigation link in the toolbar so that you could go to the next step: .toolbar { ToolbarItem { NavigationLink( "Next", state: SignUpFeature.Path.State?.none ) } } But currently that link is disabled since we stubbed in nil for the state.
— 3:38
We can now update that link to point to the personalInfo state: .toolbar { ToolbarItem { NavigationLink( "Next", state: SignUpFeature.Path.State.personalInfo( PersonalInfoFeature.State() ) ) } }
— 3:50
And with that done we can now drill down through two of the steps in the sign up flow, and on the last screen we can enter our name and phone number.
— 4:28
However, there is something not so ideal about how we have structured things so far. What if after entering our name and phone number we wanted to go back to the first step to double check what email we used. And then we tap “Next” to progress to step 2 again.
— 4:56
Well, the step 2 data was cleared. And it shouldn’t be too surprising that it’s cleared because that state was held directly inside the PersonalInfoFeature state, and when it was popped off the stack that state was discarded and so we lost all changes.
— 5:09
We can see this directly by looking at the logs: SignUpFeature.Action.path( .popFrom(id: #1) ) SignUpFeature.State( _path: [ #0: .basics(…) - #1: .personalInfo( - PersonalInfoFeature.State( - _firstName: "Blob", - _lastName: "McBlob", - _phoneNumber: "" - ) - ) ] )
— 5:13
The state we entered has been removed from the stack and so has been completely discarded. If we go back into the personal info screen all the fields have reverted back to empty strings.
— 5:24
In order to keep those changes we need extra coordination from the parent feature. Maybe after each action is processed it would store a local copy of each step’s state so that it could restore it when a step is pushed onto the stack.
— 5:42
But that sounds really messy, and it sounds like something we would have done before we had a first class tool for sharing state in features. Let’s try making use of the @Shared property wrapper to share the sign up data with multiple features, and that way it will not lose data when popping a screen off the stack.
— 6:00
First we will model a struct that holds all the data that we want to collect from the sign up form: struct SignUpData: Equatable { var email = "" var firstName = "" var lastName = "" var password = "" var passwordConfirmation = "" var phoneNumber = "" }
— 6:35
And then features will hold onto a shared reference to this state so that it can freely make changes to it, but also so that every other feature can see those changes.
— 6:50
For example, the BasicsFeature will now hold onto a @Shared of SignUpData rather than the individual fields: @Reducer struct BasicsFeature { @ObservableState struct State { @Shared var signUpData: SignUpData } enum Action: BindableAction { case binding(BindingAction<State>) } var body: some ReducerOf<Self> { BindingReducer() } } Note that we are not providing a default value for this shared state because it must be passed in from the parent. This view will no longer own that state, and instead it will be owned somewhere higher up in the feature.
— 7:04
And then the BasicsStep view will derive bindings going through that shared state: var body: some View { Form { Section { TextField("Email", text: $store.signUpData.email) } Section { SecureField("Password", text: $store.signUpData.password) SecureField("Password confirmation", text: $store.signUpData.passwordConfirmation) } } … }
— 7:22
And the preview needs to be updated to seed the view with some shared sign up data: #Preview("Basics") { NavigationStack { BasicsStep( store: Store(initialState: BasicsFeature.State(signUpData: Shared(SignUpData()))) { BasicsFeature() } ) } }
— 7:49
We will give the same treatment to the PersonalInfoFeature reducer too: @Reducer struct PersonalInfoFeature { @ObservableState struct State { @Shared var signUpData: SignUpData } enum Action: BindableAction { case binding(BindingAction<State>) } @Dependency(\.dismiss) var dismiss var body: some ReducerOf<Self> { BindingReducer() } }
— 7:58
And the view: var body: some View { Form { Section { TextField("First name", text: $store.signUpData.firstName) TextField("Last name", text: $store.signUpData.lastName) TextField("Phone number", text: $store.signUpData.phoneNumber) } } … }
— 8:10
And the preview: #Preview("Personal info") { NavigationStack { PersonalInfoStep( store: Store(initialState: PersonalInfoFeature.State(signUpData: Shared(SignUpData()))) { PersonalInfoFeature() } ) } }
— 8:21
Things are almost fully compiling, but now anywhere we are constructing a navigation link for going to the next step we have a problem. We need to pass along shared state to those features, for example in the basics view when drilling down to the personal info feature: .toolbar { ToolbarItem { NavigationLink( "Next", state: SignUpFeature.Path.State.personalInfo( PersonalInfoFeature.State(signUpData: <#Shared<SignUpData>#>) ) ) } }
— 8:36
However, we don’t have access to the Shared<SignUpData> at this point. All we have access to is the actual sign up data: store.signUpData as SignUpData
— 9:09
The property wrapper is currently completely hiding from us that secretly, under the hood, there is a shared reference wrapping the data. On the one hand the point of property wrappers is to hide information from us, but on the other hand it can be handy to lift the veil every once in awhile in order to get access to the reference type.
— 9:43
Property wrappers do provide a way to surface some of that hidden behavior, and that’s through what is known as its “projected value.” If a property wrapper is given a projected value, then you can use the $ syntax to get access to more than just the simple, wrapped data. The @Bindable property wrapper uses this feature to expose $store to us, which is what allows us to derive bindings from the store.
— 10:15
All we have to do is add a computed property to Shared called projectedValue , and we will choose to project to the Shared type: @Observable @propertyWrapper final class Shared<Value> { … var projectedValue: Shared { self } }
— 10:34
That means that when using the @Shared property wrapper you can reach for the $ -prefixed variable to get access to the underlying shared reference.
— 11:04
With that done we can now get access to the shared sign up data in the basics view in order to pass it along to the personal info view: NavigationLink( "Next", state: SignUpFeature.Path.State.personalInfo( PersonalInfoFeature.State(signUpData: store.$signUpData) ) )
— 11:17
And the last compiler error is when constructing the navigation link that drills down to the first step of the sign up process: NavigationLink( "Sign up", state: SignUpFeature.Path.State.basics(BasicsFeature.State()) )
— 11:28
We again need to pass along shared state, but this time we don’t have access to any shared state. So far the shared state has only been held in features that are in the stack, but here we need it at the root feature.
— 11:38
This just means that the root feature needs to hold onto shared state so that it has something to pass down to child features. So, let’s add some shared sign up data to the root SignUpFeature : @Reducer struct SignUpFeature { @ObservableState struct State { var path = StackState<Path.State>() @Shared var signUpData: SignUpData } … }
— 11:58
Which means we need to seed our view’s store with some shared data: struct SignUpFlow: View { @Bindable var store = Store(initialState: SignUpFeature.State(signUpData: Shared(SignUpData()))) { SignUpFeature() } … }
— 12:13
And now we have some shared data to pass along to the navigation link: NavigationLink( "Sign up", state: SignUpFeature.Path.State.basics( BasicsFeature.State(signUpData: store.$signUpData) ) )
— 12:20
Now everything is compiling, and everything works as it did before, except better. We can now navigate to and from steps and the data is not cleared. Because the “source of truth” for the sign up data has been moved to the parent and shared with the children, it will no longer be cleared out when moving back in the steps. Adding another step
— 13:21
So, the @Shared state property wrapper is already giving us an easy win. By having each step of the flow hold onto shared state instead of a simple values, and by having the parent domain feed the shared state to each child, we are instantly getting the ability to preserve the data entered in each step, even if you go back-and-forth between steps. Stephen
— 13:43
So that’s a huge win, but things get even easier for us as we add more steps. So, let’s do just that. The third and final step of our sign up flow will ask the user to choose from a set of topics that they are interested in. This will also allow us to dip our toes into some validation logic in order to make these steps seem a little more interesting.
— 14:03
Let’s update the SignUpData type to hold onto more data we want the user to enter. They will have a choice of a few topics they may be interested in, which we will represent as a set of enum values: struct SignUpData: Equatable { var email = "" var firstName = "" var lastName = "" var password = "" var passwordConfirmation = "" var phoneNumber = "" var topics: Set<Topic> = [] enum Topic: String, Identifiable, CaseIterable { case advancedSwift = "Advanced Swift" case composableArchitecture = "Composable Architecture" case concurrency = "Concurrency" case modernSwiftUI = "Modern SwiftUI" case swiftUI = "SwiftUI" case testing = "Testing" var id: Self { self } } }
— 14:22
We will model this feature like we did the previous ones: @Reducer struct TopicsFeature { @ObservableState struct State { @Shared var signUpData: SignUpData } enum Action: BindableAction { case binding(BindingAction<State>) } var body: some ReducerOf<Self> { BindingReducer() } }
— 14:37
But this time we are going to layer on some more complex logic. We will do that in just a moment.
— 14:43
The view for this feature is a little more complex than previous views: struct TopicsStep: View { @Bindable var store: StoreOf<TopicsFeature> var body: some View { Form { Section { Text("Please choose all the topics you are interested in.") } Section { ForEach(SignUpData.Topic.allCases) { topic in Toggle( topic.rawValue, isOn: $store.signUpData.topics[contains: topic] ) } } } .navigationTitle("Topics") .toolbar { ToolbarItem { NavigationLink( "Next", state: SignUpFeature.Path.State?.none ) } } } } extension Set { fileprivate subscript(contains element: Element) -> Bool { get { self.contains(element) } set { if newValue { self.insert(element) } else { self.remove(element) } } } } #Preview("Topics") { NavigationStack { TopicsStep( store: Store(initialState: TopicsFeature.State(signUpData: Shared(SignUpData()))) { TopicsFeature() } ) } }
— 14:52
It loops over all the topics in the Topic enum in order to render a Toggle .
— 14:55
It uses a custom contains subscript in order to deriving a Binding<Bool> from a Binding<Set<T>> . This is a handy way to leverage dynamic member lookup for deriving bindings instead of creating ad hoc bindings yourself.
— 15:18
But with that done we can run the preview to see that in isolation it does seem to work. Now let’s make it so that you can navigate to this new step.
— 15:28
We start by adding the topics feature to the Path reducer, which can be done with a single line: @Reducer enum Path { case basics(BasicsFeature) case personalInfo(PersonalInfoFeature) case topics(TopicsFeature) }
— 15:39
It’s incredible how easy it is to add features to the path.
— 15:43
And with that done we immediately get a compiler error in the destination closure that we can fix by handling the new topics case: } destination: { store in switch store.case { case let .basics(store): BasicsStep(store: store) case let .personalInfo(store): PersonalInfoStep(store: store) case let .topics(store): TopicsStep(store: store) } }
— 15:57
And now in the PersonalInfoStep view we can link to the topics step: .toolbar { ToolbarItem { NavigationLink( "Next", state: SignUpFeature.Path.State.topics( TopicsFeature.State(signUpData: store.$signUpData) ) ) } }
— 16:18
And with that we are now able to navigate through the 3 steps of the sign up flow, and data is kept in memory even if we go back in the steps.
— 16:36
There is one final screen we want to add to this flow which will help us really show the power of shared state. It will be a summary screen that allows the user to review all the information they entered before finally submitting.
— 16:47
So, we will add a reducer for the feature: @Reducer struct SummaryFeature { @ObservableState struct State { @Shared var signUpData: SignUpData } enum Action { case submitButtonTapped } }
— 16:54
Here we have not added the binding action because we’re not sure we need it for the summary, and there will be one user action for submitting the sign up form.
— 17:02
Next we are going to paste in a basic view that displays all the information from the form for review: struct SummaryStep: View { let store: StoreOf<SummaryFeature> var body: some View { Form { Section { Text(store.signUpData.email) Text(String(repeating: "•", count: store.signUpData.password.count)) } header: { Text("Required info") } Section { Text(store.signUpData.firstName) Text(store.signUpData.lastName) Text(store.signUpData.phoneNumber) } header: { Text("Personal info") } Section { ForEach(store.signUpData.topics.sorted(by: { $0.rawValue < $1.rawValue })) { topic in Text(topic.rawValue) } } header: { Text("Favorite topics") } Section { Button { store.send(.submitButtonTapped) } label: { Text("Submit") } } } .navigationTitle("Summary") } } #Preview("Summary") { NavigationStack { SummaryStep( store: Store( initialState: SummaryFeature.State( signUpData: Shared( SignUpData( email: " [email protected] ", firstName: "Blob", lastName: "McBlob", password: "blob is awesome", passwordConfirmation: "blob is awesome", phoneNumber: "212-555-1234", topics: [ .composableArchitecture, .concurrency, .modernSwiftUI ] ) ) ) ) { SummaryFeature() } ) } }
— 17:15
There’s nothing too special in this view. Just a basic form with a section for each step, and each step uses non-editable Text views for displaying the information entered on the previous screens.
— 17:17
With that basic feature in place we want to be able to navigate to it. So, we can add the summary feature to the Path reducer like we’ve done with all the other reducer: @Reducer enum Path { case basics(BasicsFeature) case personalInfo(PersonalInfoFeature) case summary(SummaryFeature) case topics(TopicsFeature) }
— 17:31
That creates a compiler error in the destination trailing closure because we now need to handle the new summary case: } destination: { store in switch store.case { case let .basics(store): BasicsStep(store: store) case let .personalInfo(store): PersonalInfoStep(store: store) case let .summary(store): SummaryStep(store: store) case let .topics(store): TopicsStep(store: store) } }
— 17:44
I know we keep saying it over and over, but I think it’s just amazing how easy it is to add new features to the navigation stack.
— 17:51
And now we can navigate to the summary feature from the topics feature by updating the navigation link to point to the new summary enum state case: .toolbar { ToolbarItem { NavigationLink( "Next", state: SignUpFeature.Path.State.summary( SummaryFeature.State(signUpData: store.$signUpData) ) ) } }
— 18:13
However, let’s do something a little fancier here.
— 18:15
What if we wanted to do some validation logic before going to the summary screen? Let’s say we want to make sure the user has selected at least one topic before we allow them to finish this step. We unfortunately cannot do that with a NavigationLink because links do not allow layering on additional logic before navigating. They are very simple views where once the user taps the link, SwiftUI automatically appends the data to the path, and the drill-down animation occurs.
— 18:41
But, if we wanted to perform some logic before navigating, like say validating the form or making an API request, then we have no choice but to abandon the NavigationLink for a simpler, but more powerful, Button . And this is true of vanilla SwiftUI too.
— 18:53
So, let’s swap out the NavigationLink for a Button so that we can send an action to the store in order to validate the form: .toolbar { ToolbarItem { Button("Next") { store.send(.nextButtonTapped) } } }
— 19:07
And we’ll add this action to the enum: enum Action: BindableAction { case binding(BindingAction<State>) case nextButtonTapped }
— 19:12
And we’ll need to handle the action in the reducer: var body: some ReducerOf<Self> { BindingReducer() Reduce { state, action in switch action { case .binding: return .none case .nextButtonTapped: return .none } } }
— 19:33
If the topics set is empty upon tapping the “Next” button we want to show an alert, and otherwise we want to continue on to the summary feature: case .nextButtonTapped: if state.signUpData.topics.isEmpty { // Show alert } else { // Continue to summary feature } return .none }
— 19:59
Let’s tackle the alert branch of this conditional first. To shown an alert in a testable manner we can leverage the navigation tools that come with the library. This starts by holding onto some optional AlertState using the @Presents macro: @ObservableState struct State { @Presents var alert: AlertState<Never>? @Shared var signUpData: SignUpData }
— 20:29
Typically the generic on AlertState is the type of actions that the alert can send, but right now we don’t need any actions. The alert will simply display a message to the user and have a button for dismissing, and dismissal logic is automatically handled by the navigations tools. So in this situation it is fine to use Never to represent there are no custom actions to execute in the alert.
— 20:46
Then we can add a PresentationAction to the action domain: enum Action: BindableAction { case alert(PresentationAction<Never>) case binding(BindingAction<State>) case nextButtonTapped }
— 20:56
And use the ifLet operator to incorporate the alert logic into our core feature: var body: some ReducerOf<Self> { BindingReducer() Reduce { state, action in switch action { case .alert: return .none … } } .ifLet(\.$alert, action: \.alert) }
— 21:19
And now we can populate the alert state in the reducer to represent that we want the view to show an alert: case .nextButtonTapped: if state.signUpData.topics.isEmpty { state.alert = AlertState { TextState("Please choose at least one topic.") } } else { // Continue to summary feature } return .none
— 21:33
And to actually show the alert in the view, driven off this new alert state, we will use the alert view modifier that takes a binding to a store that has been scoped down to the alert domain: var body: some View { Form { … } .alert($store.scope(state: \.alert, action: \.alert)) }
— 21:50
And with that we can already see that the alert works in the preview.
— 22:02
Next we want to implement the logic that allows the topics feature to navigate to the summary feature when validation passes. However, in order to do that we need access to the path so that we can append some state to it. Only the SignUpFeature has access to that, and so we can communicate from this feature to the sign up feature using delegate actions.
— 22:23
We’ll add a delegate action to the TopicsFeature domain: enum Action: BindableAction { case alert(PresentationAction<Never>) case binding(BindingAction<State>) case delegate(Delegate) case nextButtonTapped enum Delegate { case stepFinished } }
— 22:39
And send that action when we want to tell the parent it is time to navigate to the next step: case .delegate: return .none case .nextButtonTapped: if state.signUpData.topics.isEmpty { state.alert = AlertState { TextState("Please choose at least one topic.") } return .none } else { return .send(.delegate(.stepFinished)) } }
— 22:59
And then the SignUpFeature can start destructuring the path action since it now wants to layer on additional logic: var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .path: return .none } } .forEach(\.path, action: \.path) }
— 23:13
And it can further destructure the topics case, and then the delegate case, and then switch on the delegate action to get exhaustive checking on the delegate: Reduce { state, action in switch action { case let .path(.element(id: _, action: .topics(.delegate(delegateAction)))): switch delegateAction { case .stepFinished: return .none } case .path: return .none } }
— 23:42
And in here we just need to append to the path : case .stepFinished: state.path.append(.summary(SummaryFeature.State(signUpData: state.$signUpData))) return .none
— 24:00
With that done we can see that validation and navigation works. And we can even navigate back and forth through the steps and state is preserved the whole time. One final touch
— 24:44
So this is already impressive. We’ve got shared state flowing through multiple features, edits to the state are preserved even if you go back-and-forth through the sign up steps. And we’ve added a bit of validation in the topics screen, which allows us to showcase a style of communicating form child to parent. Brandon
— 25:00
But let’s amp things up even more. What if we wanted to make some of the sign up data to be editable from the summary screen, but not directly inline. We want to show little sheets over the summary screen that allow editing the data, and we of course want to reuse the infrastructure we already have for the steps since they may contain complex validation logic and side effects.
— 25:23
So, let’s do that.
— 25:26
We are going to do is add little “Edit” buttons next to the “Personal info” section: } header: { HStack { Text("Personal info") Spacer() Button("Edit") { //store.send(.editPersonalInfoButtonTapped) } .font(.caption) } }
— 25:40
…and the “Favorite topics” section: } header: { HStack { Text("Favorite topics") Spacer() Button("Edit") { //store.send(.editFavoriteTopicsButtonTapped) } .font(.caption) } }
— 25:46
And when those buttons are tapped we want to present a sheet with the respective step feature so that it can make further edits to the sign up data.
— 26:00
So, we have two more actions to add to the summary’s Action enum: enum Action { case editFavoriteTopicsButtonTapped case editPersonalInfoButtonTapped case submitButtonTapped }
— 26:22
And the reducer doesn’t even have a body yet, so let’s add that and stub out each action: var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .editFavoriteTopicsButtonTapped: return .none case .editPersonalInfoButtonTapped: return .none case .submitButtonTapped: return .none } } }
— 26:46
What we want to do in each of the edit actions is mutate some state that then causes the view to present sheets. In order to present the features programmatically we will want to model a Destination domain that encapsulates all the features that can be presented, much like we did with the navigation stack, but this time it will only have the PersonalInfoFeature and TopicsFeature incorporated.
— 27:16
And amazingly we can yet again flex the muscles of the @Reducer macro by applying it to an enum that will hold a case for each feature that can be presented: @Reducer enum Destination { case personalInfo(PersonalInfoFeature) case topics(TopicsFeature) }
— 27:46
That’s all it takes to bundle up all the logic and behavior for all the features that can be presented from the summary into one single package.
— 27:52
Then we will add some optional destination state to the feature using the @Presents macro: @ObservableState struct State { @Presents var destination: Destination.State? @Shared var signUpData: SignUpData }
— 28:00
And we will add a destination presentation action: enum Action { case destination(PresentationAction<Destination.Action>) case editFavoriteTopicsButtonTapped case editPersonalInfoButtonTapped case submitButtonTapped }
— 28:09
We now have a single optional value that controls what feature is currently being presented. This is the most optimal way to model this domain since it does not make sense to present 2 sheets at once. Using an enum for the destination makes this impossible, and the compiler has our back each step of the way proving it.
— 28:36
And then we will incorporate the destinations’ logic into the core summary feature using the ifLet reducer operator, and because we are using the @Reducer macro on the Destination enum we don’t even need to specify the reducer in the trailing closure: var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .destination: return .none … } .ifLet(\.$destination, action: \.destination) }
— 29:12
And now we can start implementing some of the core logic for the summary feature. It is quite simple because when one of the “Edit” buttons is tapped we just want to populate the destination state, and the view should handle the rest: var body: some ReducerOf<Self> { Reduce { state, action in switch action { case .destination: return .none case .editFavoriteTopicsButtonTapped: state.destination = .topics( TopicsFeature.State( signUpData: state.$signUpData ) ) return .none case .editPersonalInfoButtonTapped: state.destination = .personalInfo( PersonalInfoFeature.State( signUpData: state.$signUpData ) ) return .none case .submitButtonTapped: return .none } } .ifLet(\.$destination, action: \.destination) }
— 29:54
In order to present sheets from the summary view we will need to make the store @Bindable so that we can derive bindings from it: struct SummaryStep: View { @Bindable var store: StoreOf<SummaryFeature> … }
— 30:06
And then we can use the sheet(item:) view modifier to present the personal info step and the topics step. And we will use detents in order to show a sheet that doesn’t take up the full screen: .sheet( item: $store.scope(state: \.destination?.personalInfo, action: \.destination.personalInfo) ) { personalStore in NavigationStack { PersonalInfoStep(store: personalStore) } .presentationDetents([.medium]) } .sheet( item: $store.scope(state: \.destination?.topics, action: \.destination.topics) ) { topicsStore in NavigationStack { TopicsStep(store: topicsStore) } .presentationDetents([.medium]) }
— 31:16
This gets us really close to a fully working sign up flow, but these sheets aren’t going to work exactly how we want. Right now if we navigate all the way to the summary screen and tap “Edit”, we get a sheet that has a “Next” button in the top-right. And tapping it does nothing.
— 32:01
That’s because that is technically a navigation link, and it is trying to push a piece of state onto a path that doesn’t exist. The path is in the root SignUpFeature . Not in the sheet.
— 32:12
We need to add some customization to each of the features that can be presented in a sheet so that they know when they are in being presented in the stack versus presented in a sheet.
— 32:20
The easiest way to do this is just to add some local state directly to the view: struct PersonalInfoStep: View { @Bindable var store: StoreOf<PersonalInfoFeature> var isEditingFromSummary = false … } It doesn’t even need to be @State because it isn’t ever going to change.
— 32:38
And then in the view we can decide between showing a NavigationLink or a simple “Done” button: .toolbar { ToolbarItem { if !isEditingFromSummary { NavigationLink( "Next", state: SignUpFeature.Path.State.topics( TopicsFeature.State(signUpData: store.$signUpData) ) ) } else { Button("Done") { } } } }
— 33:04
And we don’t even need to send a new action into the system for this “Done” button. We just want to dismiss the sheet, and SwiftUI’s dismiss environment works perfectly for this: @Environment(\.dismiss) var dismiss
— 33:19
Which we can call from the button: Button("Done") { dismiss() }
— 33:35
And then when presenting this view from the sheet we will customize it to let it know it is being edited from the summary view: NavigationStack { PersonalInfoStep(store: personalStore, isEditingFromSummary: true) }
— 33:49
And with that done we can run the summary preview to see it works as we expect.
— 33:55
And we can give the topics feature a similar treatment. We will add some state to the view: struct TopicsStep: View { @Bindable var store: StoreOf<TopicsFeature> var isEditingFromSummary = false … }
— 34:08
And we will customize the toolbar based on that state: .toolbar { ToolbarItem { if !isEditingFromSummary { Button("Next") { store.send(.nextButtonTapped) } } else { Button("Done") { } } } }
— 34:22
However, remember that this feature performs some validation logic before navigating, and we would want that to work in the sheet too. We wouldn’t want it to be possible that you could deselect all of the topics and then dismiss the sheet.
— 34:47
So, we will send an action when the “Done” button is tapped: .toolbar { ToolbarItem { if !isEditingFromSummary { Button("Next") { store.send(.doneButtonTapped) } } else { Button("Done") { store.send(.nextButtonTapped) } } } }
— 34:58
We’ll add that action to our domain: enum Action: BindableAction { … case doneButtonTapped … }
— 35:02
And handle that action in the reducer: case .doneButtonTapped: if state.signUpData.topics.isEmpty { state.alert = AlertState { TextState("Please choose at least one topic.") } return .none } else { // Dismiss the sheet }
— 35:24
When validation passes we want to dismiss the sheet, and luckily the Composable Architecture comes with a tool that is similar to SwiftUI’s \.dismiss environment value to accomplish this. It’s the \.dismiss dependency: @Dependency(\.dismiss) var dismiss And it can be invoked inside an effect to communicate to the parent and tell it to dismiss: case .doneButtonTapped: if state.signUpData.topics.isEmpty { state.alert = AlertState { TextState("Please choose at least one topic.") } return .none } else { return .run { _ in await dismiss() } } And then when presenting it in a sheet: NavigationStack { TopicsStep(store: topicsStore, isEditingFromSummary: true) }
— 36:01
And we can also go the extra mile by disabling interactive dismissal in the TopicsStep view: var body: some View { Form { … } .interactiveDismissDisabled() }
— 36:41
We can even make interactive dismissal contingent on whether or not the form is valid. .interactiveDismissDisabled(store.signUpData.topics.isEmpty) This makes it so that you cannot swipe down to dismiss the feature, which we want to do so that people cannot deselect all topics and then dismiss.
— 37:22
And now the sign up flow is completed, and it all works as we expect. And it’s absolutely amazing what we have accomplished here:
— 38:26
First of all, we have a complex sign up flow where each step could be completely isolated and put into their own module with no dependencies between them. This makes it possible to work on a sign up step in complete isolation without having to think of the other steps or think about the parent feature.
— 38:56
And these steps could get a lot more complex than they are now. For example, what if we wanted to perform server-side email and password validation? That would require making an API request, interpreting the response, and highlighting the invalid fields with some messaging. And you might even want to do that with every key stroke, debounced in some manner. Another example of this would be if we wanted to perform phone number validation in the personal info step. Perhaps after entering the phone number we sent the person a text message with a verification code. Then we would want a new text field to appear to verify code, and only once that is entered will the user be allowed to continue to the next step.
— 39:17
That is all very significant logic, and we would be able to quarantine it into the respective features without it bleeding out.
— 39:27
And second, our very simple @Shared property wrapper has given us the exact tool needed to allow all of these features to have access to the same state, and to make changes to the state so that every other features immediately sees it. There is no coordination to be done in the parent, and thanks to the magic of Swift’s observation tools all state changes are seamlessly observed by the view.
— 40:02
And finally, because using shared state was so easy, we felt empowered to layer on more advanced functionality without a second thought. For example, we wanted to be able to re-present steps 2 and 3 from the summary screen so that the user could make last minute edits to their data. We did that very easily without a care in the world. No need to layer on additional complex synchronization logic so that we could make sure that all of the various features are holding onto the most current state. Next time: Testing
— 40:44
And so this is absolutely incredible. We now have the basics of dedicated @Shared property wrapper that allows us to easily share data between multiple features. And we’ve seen very concretely that this allows us to create complex features quite easily.
— 40:58
Now in doing this we did have to come face-to-face with what it means to put a reference type into our state. On the one hand it’s really no different than using a dependency to model shared state. Dependencies are very reference-like, even if they are modeled as structs, and so we used that fact to justify using a reference type directly in state. And thanks to Swift’s observation tools, reference types are now observable, and so everything just worked really nicely. Stephen
— 41:22
However, what is not going to be so nice about everything we have done so far is testing. Reference types are notoriously difficult to test because they are an amalgamation of data and behavior, and because they can’t be copied. This makes it difficult to compare the data inside a reference before and after a mutation has occurred so that we can assert on how it changed in an easy and exhaustive manner.
— 41:44
Let’s see these problems concretely, and then see how we might fix them…next time! Downloads Sample code 0270-shared-state-pt3 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 .