Video #222: Composable Navigation: Tabs
Episode: Video #222 Date: Feb 13, 2023 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep222-composable-navigation-tabs

Description
It’s finally time to tackle navigation in the Composable Architecture. We’ll port the Inventory app we first built to understand SwiftUI navigation, which will push us to understand what makes the architecture “composable,” how it facilitates communication between features, and testing.
Video
Cloudflare Stream video ID: f0ab10630e710dfc5ccd17a051b40549 Local file: video_222_composable-navigation-tabs.mp4 *(download with --video 222)*
References
- Discussions
- Composable Architecture
- its own library
- an Observable protocol
- some chatter
- fixed soon
- isowords
- XCTest Dynamic Overlay
- TCA Action Boundaries
- The "delegate" pattern in isowords
- Sharing logic with actions
- Composable navigation beta GitHub discussion
- 0222-composable-navigation-pt1
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
Today we are excited to begin a series of episodes that is probably the most highly anticipated topic for Point-Free, and that is navigation tools for our popular library: the Composable Architecture .
— 0:17
We’ve hinted at this series many times in the past, but it was never quite the right time to embark on the journey. There were two really big things that needed to be accomplished first:
— 0:26
The Composable Architecture library itself needed some modernization improvements: We needed to more deeply integrate Swift’s amazing new concurrency tools into the library. In doing so, one can now create effects using async-await, and it’s easy to tie the lifetime of effects to the lifetime of views. We also revamped how one builds features in the library by putting a protocol in front of the concept of a “reducer”. This allowed us to leverage a result builder-like syntax for composing complex features. And finally we introduced a brand new dependency management system, which makes it easy to push dependencies deep into an application. We even extracted that system into its own library so that it can be used in vanilla SwiftUI, UIKit, AppKit, and even server applications. Stephen
— 1:11
So, modernizing the Composable Architecture was the first big thing we needed to accomplish. The second was we needed to conclude our extensive series discussing SwiftUI navigation from first principles since recently iOS 16 had some pretty big changes. We covered the new binding-driven navigationDestination API as well as the new collection-based NavigationStack API. We were even able to work around a few SwiftUI navigation bugs along the way, but there are still more lurking in the shadows. But the best part of all of that work is that we came across something that we like to call the “grand unified theory” of SwiftUI navigation. We found that basically all forms of navigation in SwiftUI can be united with very similar APIs. Everything from alerts, sheets and popovers, to even drill downs. And that was incredible to see, and really helps clear the fog when it comes to the complexities of navigation. Brandon
— 2:06
With all of that foundational work completed we are now ready to start layering on navigation tools in the Composable Architecture. Once we are done we will find that if you put in a little upfront domain modeling work when creating your features, you will unlock some amazing super powers. Most, if not all, of the problems we encountered when dealing with vanilla SwiftUI are fixed in the Composable Architecture, and we have access to all new capabilities that we couldn’t even have dreamed of with vanilla SwiftUI!
— 2:36
So, let’s get started. The inventory application
— 2:38
We are going to revisit the Inventory application that we used as the foundation for building up our knowledge about the essence of navigation. It’s a pretty simple app, but it allowed us to explore a multi-screen application that had many forms of navigation, including tabs, alerts, sheets and popovers, drill downs, and required various ways for parent and child domains to communicate with each other.
— 3:00
You may be wondering why we aren’t rebuilding the Standups app that we built during our “Modern SwiftUI” series, which just wrapped up. Well, that application is just a little bit too complex to tackle while simultaneously trying to uncover all new navigation tools, so we are going to revisit that application at a later time.
— 3:18
Let’s start by showing the app we built last time so that we can remember everything it entailed. We are going to open the version of the Inventory app that we actually ship as a demo inside our SwiftUI Navigation library.
— 4:20
Now let’s get a fresh, clean slate in place, and start getting our first piece of UI in place, which is the tab view at the root of the application. We did this in order to explore the basic idea of state-driven navigation. There were 3 tabs in the app, the first and third were just stubs, and the middle tab is what we eventually built out to be the inventory functionality: struct ContentView: View { var body: some View { TabView { Text("One") .tabItem { Text("One") } Text("Inventory") .tabItem { Text("Inventory") } Text("Three") .tabItem { Text("Three") } } } }
— 4:56
That’s already enough to get something in a preview: struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() } } So it’s pretty impressive that with just a few lines of code SwiftUI can already display a tab view, and we can even tap around on it to switch tabs.
— 5:05
However, what we cannot do is programmatically change the tab. For example, there is no way to start the preview in a specific state where the inventory or last tab is already selected. It’s also not possible to add a button that switches the tab, like say to the first tab: Button { <#???#> } label: { Text("Go to inventory") } .tabItem { Text("One") }
— 5:27
There is nothing we can do in the action closure of that button because there is no state that can be mutated in order to tell SwiftUI to update the visual appearance of the UI so that the inventory tab is selected.
— 5:51
So, that is what motivated us to add some state to the ContentView in our previous series of episodes. We started with some local state modeled on the @State property wrapper, and even introduced a dedicated enum to describe each tab: enum Tab { case one, inventory, three } struct ContentView: View { @State var selectedTab: Tab = .one var body: some View { TabView(selection: self.$selectedTab) { Button { self.selectedTab = .inventory } label: { Text("Go to inventory") } .tabItem { Text("One") } .tag(Tab.one) Text("Inventory") .tabItem { Text("Inventory") } .tag(Tab.inventory) Text("Three") .tabItem { Text("Three") } .tag(Tab.three) } } }
— 6:35
This helps us implement the button for changing to the inventory tab, and technically we can even start the view with a particular tab selected: ContentView(selectedTab: .inventory)
— 7:14
However, passing data to a @State property wrapper like this works only the very first time the view is created. If you need to later change the selected tab from a parent domain, you are out of luck. It won’t work. And this is because @State controls the lifetime of its state, and it cannot be influenced from the outside, other than on first initialization.
— 7:37
That is what finally motivated us to extract the state into a proper ObservableObject , which allows to solve both problems: start the screen with a specific tab selected and change to a selected tab anytime we want by just mutating some state.
— 7:55
It’s straightforward to add the ObservableObject , and we did it in our previous series, so this time we are going to jump straight to doing it in the style of the Composable Architecture. At first it will seem like a little more ceremony to implement, but over time we will see that little bit of upfront work pays dividends in how domains communicate with each other and how tests are written.
— 8:16
So, we start with a new type that conforms to the reducer protocol: import ComposableArchitecture struct AppFeature: ReducerProtocol { }
— 8:29
And to get access to the Composable Architecture we will need to add it to our project.
— 8:37
Now, we aren’t actually going to depend on the latest official release of the Composable Architecture here. In conjunction with releasing this first episode of the navigation series we have also officially started the process of moving the Composable Architecture towards its 1.0 release. We feel that navigation tools are the only thing missing from the library before we can release 1.0.
— 9:02
So, there are some prerelease branches we can target in order to get a preview of what the future version of the library looks like. We are actually maintaining a few prerelease branches of various levels of severity so that you can ease yourself into the 1.0 release at your own pace.
— 9:19
We have one branch that turns all soft deprecations into hard deprecations so that you can still have a compiling project but also understand which things will be removed or renamed in the final 1.0.
— 9:31
And then we have a branch that will actually start to remove and rename deprecated APIs. Both of these branches will always track the most recent release, and so if you want to get a jump start on things you can pin your projects to one of them.
— 9:48
It’s also worth noting that all of the navigation tools we build will be released before the official 1.0, maybe as a 0.51.0 or 0.52.0, and so you will not need to upgrade all the way to 1.0 just to get the navigation tools. Really the 1.0 doesn’t add any new features, it just removes cruft, which is a breaking change.
— 10:13
In order to keep these episodes as future forward as possible, we are going to pin our dependence to the prerelease/1.0 branch, which means it has everything from the most recent public release, but also some APIs have been removed and renamed. And if you’re watching this episode in the future after 1.0 has been released, you can just pin to that version.
— 10:46
And with that we are already getting a deprecation warning because ReducerProtocol has been renamed to the much nicer, and shorter, Reducer : struct AppFeature: Reducer { }
— 10:59
Ahhh, that’s like a breath of fresh air.
— 11:06
The first step to implementing the requirements of the protocol is to create a type for the state the feature needs to do its job, which is usually a struct, as well as a type for the actions that can be performed in the feature, which is usually an enum: struct AppFeature: Reducer { struct State {} enum Action {} }
— 11:21
Currently the only state we care about is what tab is selected, so we can add that: struct State { var selectedTab: Tab = .one }
— 11:28
And the only action that can occur is when the user selects a tab: enum Action { case selectedTabChanged(Tab) }
— 11:39
Next there are two different ways we can finish up the implementation of the Reducer protocol. The simplest, and usually the best way to start out, is to implement a reduce method that is responsible for the logic and behavior of the feature. It is handed an inout value of the feature’s current state, as well as an action that has occurred in the feature. It’s the reduce method’s job to mutate the state based on that action, and return an effect, which is a unit of work that can run asynchronously, communicate with the outside world, and feed data back into the system: struct AppFeature: Reducer { struct State {} enum Action {} func reduce( into state: inout State, action: Action ) -> Effect<Action> { } }
— 12:18
Notice that we get to use the simple syntax of Effect<Action> . In the 1.0 release of the library you no longer need to specify an action and failure, since the failure must always be Never .
— 12:34
The easiest way to implement this method is to switch on the action and handle each case individually: func reduce( into state: inout State, action: Action ) -> Effect<Action> { switch action { case let .selectedTabChanged(tab): state.selectedTab = tab return .none } }
— 12:50
Currently we don’t have any effects to execute so we will return the .none effect, which does nothing and completes immediately.
— 12:55
That is the basics of creating a simple feature in the Composable Architecture, at least as far as the domain is considered. There’s still a lot more to know about for building features in the library, such as composing features and using dependencies, but before considering any of that, let’s update the view so that it can observe the state of our newly created AppFeature and send user actions into the system.
— 13:14
We no longer need the view to hold onto local @State or observable objects, and instead we will hold onto what is known as a Store . This is a tool from the Composable Architecture, and it represents the actual “runtime” of the feature. This means it’s the thing that applies mutations to state, runs effects asynchronously, feeds their emissions back into the system, and more.
— 13:38
A store can be added to the view like so: struct ContentView: View { let store: StoreOf<AppFeature> … }
— 13:45
The Store type has two generics, one for the state that is held on the inside and another for the actions that can be sent into the store: let store: Store<AppFeature.State, AppFeature.Action> …but the StoreOf type alias is a shortcut to allow those generics to be automatically determined by the type of the reducer, in this case AppFeature : let store: StoreOf<AppFeature>
— 14:13
Now, the store represents the “runtime” of the feature, but you cannot actually read state from it or send actions to it. This is why it’s held onto as a simple let rather than an @ObservedObject .
— 14:24
There is an additional step that must be taken to observe state and send actions, and it’s done using a special SwiftUI view known as WithViewStore : WithViewStore( <#Store<ViewState, ViewAction>#>, observe: <#(State) -> ViewState#>, content: <#ViewStore<ViewState, ViewAction>) -> Content#> )
— 14:35
You hand it a store, as well as a transformation to describe what parts of the feature’s state you want to observe, and then it hands you what is known as a ViewStore , which is the thing you can use to access state and send actions.
— 14:53
The reason this extra step is necessary is because it makes it possible for you to whittle down your feature’s state to the bare minimum that is needed for the view. Often times, and perhaps even the majority of times, the state held in a feature is much larger than what the view needs. It may hold onto state that is only used for its business logic but never actually displayed, or more likely, it may hold onto state for child features that are not actually used in the UI. This is especially prevalent in Composable Architecture applications due to its insistence on composing many features together into one main feature.
— 15:33
Observing too much state is by far the #1 source of performance problems and glitches in SwiftUI. If you were to naively observe all of state in all views: WithViewStore(self.store, observe: { $0 }) { viewStore in TabView { … } }
— 15:48
…then you would cause the view to be recomputed anytime any state changes inside the feature. Now, currently we only have a selectedTab field in our state, but soon we will be holding onto the state for each feature in the tab, and it would be really strange to recompute the view just because some little piece of state changes in some tab. That seems incredibly wasteful.
— 16:13
So, this why constructing WithViewStore requires you to pass along an observe closure. It encourages you to observe the bare minimum necessary for the view and not to just observe everything.
— 16:23
In this case, we only need the selectedTab , and so let’s just observe that: WithViewStore( self.store, observe: \.selectedTab ) { viewStore in TabView(…) { … } }
— 16:30
It’s worth noting that this style of being selecting with exactly what state you are observing may someday soon be fully automated thanks to a new feature of Swift. There’s a pitch in Swift evolution that proposes an Observable protocol that that makes it possible to observe only the pieces of state accessed in a data type.
— 16:53
But, with that done, we can derive a binding from the viewStore to that single piece of state by using the binding helper and specifying what action to send when the binding is mutated: TabView( selection: viewStore.binding( send: AppFeature.Action.selectedTabChanged ) ) { … }
— 17:15
Further, we can send that same action to the view store when the button is tapped that should switch the tab view to the inventory tab: Button { viewStore.send(.selectedTabChanged(.inventory)) } label: { Text("Go to inventory") }
— 17:24
That gets mostly everything compiling, but now anywhere we are constructing the ContentView we need to supply a store.
— 17:28
For example, in the preview we can construct a store by specifying the initial state we want the view to start in, as well as the reducer that will power its logic and behavior: struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView( store: Store( initialState: AppFeature.State(), reducer: AppFeature() ) ) } }
— 17:42
And the same can be done with the entry point of the application: import ComposableArchitecture import SwiftUI @main struct InventoryApp: App { var body: some Scene { WindowGroup { ContentView( store: Store( initialState: AppFeature.State(), reducer: AppFeature() ) ) } } }
— 18:03
That now gets everything compiling, and the application should work exactly as it did before. Feature composition Brandon
— 18:21
It’s still not a very impressive app. It’s just a simple tab view driven by some state, and a button in the first tab that can cause the tab view to switch the second tab. We will be beefing up the functionality of this soon enough, but there are few more things to explore before doing that. Stephen
— 18:42
Let’s start with a little bit more domain modeling. The way we have our tab view structured right now is probably not how anyone would do it in a real application. We just have all of the views inlined directly in the root AppView but typically we would extract each tab into its own dedicated View protocol conformance. And SwiftUI makes this incredibly easy to do.
— 19:05
Let’s start with the first tab. We’ll create a file to house its domain.
— 19:14
And we will cut and paste the button from the main app view into a brand new FirstTabView : import SwiftUI struct FirstTabView: View { var body: some View { Button { // viewStore.send(.selectedTabChanged(.inventory)) } label: { Text("Go to inventory tab") } } }
— 19:31
We’re commenting out the viewStore stuff for now because we don’t currently have access to the viewStore in this view. Sure we could pass it through, but there’s going to be a better way to handle this in just a moment.
— 19:49
Next we can extract out the inventory tab’s view into its own file.
— 19:56
And we’ll get a simple view into place: import SwiftUI struct InventoryView: View { var body: some View { Text("Inventory") } }
— 20:04
And finally a brand new file and View conformance for the third tab. import SwiftUI struct ThirdTabView: View { var body: some View { Text("Three") } }
— 20:15
With those basic stubs of views in place, we can now construct those views to be the root of each tab in the TabView : TabView( selection: viewStore.binding( send: AppFeature.Action.selectedTabChanged ) ) { FirstTabView() .tabItem { Text("One") } .tag(Tab.one) InventoryView() .tabItem { Text("Inventory") } .tag(Tab.inventory) ThirdTabView() .tabItem { Text("Three") } .tag(Tab.three) }
— 20:20
This is really powerful stuff. SwiftUI makes it incredibly easy to extract out little views into their own units so that they can be developed in isolation, and then you can compose them together in some really interesting ways.
— 20:31
And the really cool thing is that the Composable Architecture gives you tools to do the same, except for your features’ logic and behavior rather than their views. One of the big benefits of the Composable Architecture is the ability to split an application into many small features while still being able to glue them together with very little work.
— 20:47
Let’s give this a shot because there are already 3 clear features in this application besides the AppFeature , which currently only manages the selected tab. The features are precisely the features that live in each of the tabs of the TabView . There’s the “One” tab, the “Inventory” tab, and the “Three” tab.
— 21:02
Let’s quickly sketch out Composable Architecture features for these domains. I’m even going to put the domain for each of these features in the same file where the view lives, but with a little bit of additional work we could even put each domain and view into their own Swift module. That would mean we could compile each feature in complete isolation without worrying about incurring the cost of compiling the entire application.
— 21:22
We’ll start with the FirstTabFeature : import ComposableArchitecture struct FirstTabFeature: Reducer { struct State {} enum Action {} func reduce( into state: inout State, action: Action ) -> Effect<Action> { } }
— 21:47
…which means the view will now take a StoreOf<FirstTabFeature> : struct FirstTabView: View { let store: StoreOf<FirstTabFeature> … }
— 21:56
Then the InventoryFeature : import ComposableArchitecture struct InventoryFeature: Reducer { struct State {} enum Action {} func reduce( into state: inout State, action: Action ) -> Effect<Action> { } } … struct InventoryView: View { let store: StoreOf<InventoryFeature> … }
— 22:06
And then the ThirdTabFeature : import ComposableArchitecture struct ThirdTabFeature: Reducer { struct State {} enum Action {} func reduce( into state: inout State, action: Action ) -> Effect<Action> { } } … struct ThirdTabView: View { let store: StoreOf<ThirdTabFeature> … }
— 22:10
With those stubs of domains we now have a place to start implementing all of the logic for each feature. We get to work in these little areas in full isolation without having to worry about what is happening over in the other tabs, just like how SwiftUI allows us to do for the view layer. These reducers will be responsible for all state mutations and all effect executions in each tab.
— 22:28
Once we start getting some real state and behavior into these features we will also start using WithViewStore so that we can start observing state changes and send actions into the system.
— 22:37
But, as soon as we do this we are met with compiler errors because now it’s no longer possible to construct the FirstTabView , InventoryView or ThirdTabView without supplying a Store : Missing argument for parameter ‘store’ in call And we have a store , but it’s not of the right type: FirstTabView(store: self.store) Cannot convert value of type ‘StoreOf<AppFeature>’ (aka ‘Store<AppFeature.State, AppFeature.Action>’) to expected argument type ‘StoreOf<FirstTabFeature>’ (aka ‘Store<FirstTabFeature.State, FirstTabFeature.Action>’)
— 22:49
The way we solve this is to compose all of our features together into one feature so that we can derive a whole new store from this root one that is tailored specifically for each of the tabs.
— 23:00
We can start by integrating all of the domains together. The AppFeature represents the domain of the entire application, which consists of 3 tabs as well as a piece of state for the currently selected tab. So, let’s literally hold the state for each of the other features directly in the AppFeature ’s state: struct AppFeature: Reducer { struct State { var firstTab = FirstTabFeature.State() var inventory = InventoryFeature.State() var selectedTab: Tab = .one var thirdTab = ThirdTabFeature.State() } … }
— 23:30
Something similar can be done with AppFeature ’s actions, except it will hold a case for each of the tab’s actions: struct AppFeature: Reducer { struct State { … } enum Action { case firstTab(FirstTabFeature.Action) case inventory(InventoryFeature.Action) case selectedTabChanged(Tab) case thirdTab(ThirdTabFeature.Action) } … }
— 23:54
So, we’ve now integrated the domains of all the tabs together by combining their states and actions. Next we must do the same for the reducer, which implements the logic and behavior, and this is where things get really interesting.
— 24:07
Remember that the reduce method is the true brains of the feature. It’s what processes an incoming user action, figures out how to mutate the current state to its next value, and determines what effects to execute.
— 24:18
Just as the State struct and Action enum have been enlarged to contain all of the domain from the tabs, so too should the reduce method. It needs to somehow process the actions from each of the tabs and run the respective reducer: func reduce( into state: inout State, action: Action ) -> Effect<Action> { switch action { case let .selectedTabChanged(tab): state.selectedTab = tab return .none case .firstTab, .inventory, .thirdTab: <#???#> } }
— 24:42
Well, there is a really nice way to do this, and it uses the other way of conforming to the Reducer protocol that we alluded to earlier.
— 24:53
Instead of implementing the reduce method you can implement the body property, which allows you to compose many reducers together: var body: some Reducer<State, Action> { }
— 25:02
This signature looks similar to when creating View protocol conformances, but it’s leveraging some new, advanced features of Swift. Reducers have two very important and very public associated types, the State and Action , which are known as primary associated types. That is what allows us to use the syntax: Reducer<State, Action>
— 25:19
…to represent the Reducer protocol with those two associated types constrained to the concrete types defined above.
— 25:30
That works in tandem with Swift’s opaque result type feature: some Reducer<State, Action>
— 25:34
…in order to express that from the body property we will be returning some kind of concrete type that conforms to the Reducer protocol with those concrete associated types. The actual type we return will be some ginormous, gnarly type, but it will conform to the protocol nonetheless.
— 25:50
The library also comes with a helper type alias that allows us to shorten this a bit: var body: some ReducerOf<Self> { }
— 26:06
In this body property you get to list out many reducers that you want to compose together, and the underlying result builders will do the work behind the scenes to construct one big reducer that runs the reducers one after another and merges all of their effects together. It’s quite reminiscent of how composing views together in SwiftUI works.
— 26:24
So, you might hope you can just do something like this: var body: some ReducerOf<Self> { FirstTabFeature() InventoryFeature() ThirdTabFeature() } Static method ‘buildExpression’ requires the types ‘AppFeature.State’ and ‘FirstTabFeature.State’ be equivalent Static method ‘buildExpression’ requires the types ‘AppFeature.Action’ and ‘FirstTabFeature.Action’ be equivalent
— 26:33
However, that does not work, and it’s specifically because of the primary associated types, of which SwiftUI’s View protocol has none. Because all of these reducers speak completely different state and actions, the library can’t possible know how to glue these reducers together.
— 26:50
What we need to do is describe how one transforms the domains of the FirstTabFeature , InventoryFeature , and ThirdTabFeature so that they can plugged into the AppFeature . There is a tool in the library that allows us to do this, and it looks a lot like composing views together in SwiftUI.
— 27:06
It’s called Scope , and it takes 2 transformations and a trailing closure that acts as an entry point into builder syntax: Scope( state: <#WritableKeyPath<ParentState, Child.State>#>, action: <#CasePath<ParentAction, Child.Action>#>, <#child: () -> Child#> )
— 27:20
The first argument asks you to describe how one can isolate the child feature’s state from the parent feature’s state. It needs a writable key path because it needs to be able to both read the child state from the parent and mutate that state. We can even see what our options are here by typing \. in order to get some autocomplete: Scope( state: \.<#⎋#>, action: <#CasePath<ParentAction, Child.Action>#>, <#child: () -> Child#> )
— 27:37
And we instantly see that we can specify any field on AppFeature.State . So, let’s start with the firstTab state: Scope( state: \.firstTab, action: <#CasePath<ParentAction, Child.Action>#>, <#child: () -> Child#> )
— 27:43
The next argument asks you to describe how one can isolate the child feature’s actions from the parent feature’s actions. This sounds very similar to what we just did for state, but because actions are modeled on enums and not structs, this argument is of type CasePath . This is a concept that we first introduced 3 years ago, and it allows one to abstract over the shape of enums like key paths allow for structs.
— 28:04
Unfortunately, since case paths are not native to the Swift language we don’t get the same affordances that we got with the state key path. Instead it is on us to manually construct a case path, which we can do by initializing the CasePath type with the case of the action enum we want to isolate: Scope( state: \.firstTab, action: CasePath(Action.firstTab), <#child: () -> Child#> )
— 28:24
That works, but if you have an appetite for it you can shorten this a bit more by using an operator. The Case Paths library overloads the forward slash operator for deriving a case path from an enum case, which helps it look like the dual notion to key paths: Scope( state: \.firstTab, action: /Action.firstTab, <#child: () -> Child#> )
— 28:44
We personally think it is a huge oversight of Swift to not support the concept of case paths natively. There has been some chatter in the Swift evolution forums for bringing case paths to the language, and if that ever happens we might even be able to write code like this: Scope( state: \.firstTab, action: \.firstTab, <#child: () -> Child#> )
— 28:59
…and we would even get the benefits of type inference and autocomplete.
— 29:03
But, sadly that is not the reality today, so let’s revert that.
— 29:07
The third and final argument for Scope is a trailing closure that gives you a builder syntax to specify a reducer to run on the child state and actions that were just specified by the first two arguments. In fact, that trailing closure carves out the exact place that we can now run our FirstTabFeature reducer: Scope(state: \.firstTab, action: /Action.firstTab) { FirstTabFeature() }
— 29:25
And amazingly that compiles.
— 29:30
We can repeat this exact same process for the other two tabs: var body: some ReducerOf<Self> { Scope(state: \.firstTab, action: /Action.firstTab) { FirstTabFeature() } Scope(state: \.inventory, action: /Action.inventory) { InventoryFeature() } Scope(state: \.thirdTab, action: /Action.thirdTab) { ThirdTabFeature() } }
— 29:52
This little bit of glue code allows all 3 tab features to run side-by-side. However, we’ve lost the original little bit of reducer we originally wrote. We can use the Reduce reducer to layer on a bit of extra functionality via a closure: var body: some ReducerOf<Self> { Reduce { state, action in switch action { case let .selectedTabChanged(tab): state.selectedTab = tab return .none case .inventory, .firstTab, .thirdTab: return .none } } … }
— 30:13
And sometimes with complex Reduce expressions the compiler can lose autocomplete and other features, and so it can be good to specify the generics too: Reduce<State, Action> { state, action in … }
— 30:54
This limitation is a bug in the compiler that Apple knows about, and hopefully it will be fixed soon .
— 31:01
We now have one single reducer that secretly incorporates the logic and behavior of 4 reducers: one for each tab and one for the root-level app. When an action comes in, whether it is for the root-level feature or one of the tabs, it goes through each of these composed reducers so that each can attempt to run its logic on the action. If the action doesn’t match the domain, such as if the InventoryFeature is trying to process a FirstTabFeature ’s action, then it will just breeze past without anything happening.
— 31:33
Also, since all 4 features are fully integrated together, this means the root-level reducer can instantly inspect the state of any child features, and even listen to actions happening on the inside. We will make use of this very soon.
— 31:45
So, things are looking pretty good with all of the domains composed, but we still have compilation errors in the view because we don’t have a store to pass to each of the tab views. But, we can accomplish this now that all of our domains are composed together.
— 32:00
There is a compositional operator on Store that allows you to derive a whole new store that focuses in on just a small subset of the domain, such as the domain of a single tab. It is also called scope , but it is a method on Store : FirstTabView( store: self.store.scope( state: <#(AppFeature.State) -> ChildState#>, action: <#(ChildAction) -> AppFeature.Action#> ) )
— 32:18
We again have to provide two transformations, but this time they are simple functions rather than key paths and case paths. We need a state transformation for plucking the child state from the parent: FirstTabView( store: self.store.scope( state: \.firstTab, action: <#(ChildAction) -> Action#> ) )
— 32:31
And we need an action transformation for embedding a child action into the parent. The case of the Action enum works great for this: FirstTabView( store: self.store.scope( state: \.firstTab, action: AppFeature.Action.firstTab ) )
— 32:45
This is now compiling, and we can do something similar for the other two tabs: InventoryView( store: self.store.scope( state: \.inventory, action: AppFeature.Action.inventory ) ) … ThirdTabView( store: self.store.scope( state: \.thirdTab, action: AppFeature.Action.thirdTab ) ) …
— 33:13
Now everything compiles and everything is integrated together, both domain and views, yet we can still work on each individual feature in isolation.
— 33:22
However, the “Go to inventory” button no longer works. That’s because we commented out the code that tried sending an action to the view store when that button was tapped: Button { // viewStore.send(.selectedTabChanged(.inventory)) } label: { Text("Go to inventory tab") }
— 33:32
And we did that because it didn’t really make sense to pass a viewStore down to this view just so that we can send this action. After all, the viewStore from the root is cognizant of all of the app’s domain, and eventually we’d like to be able to split this tab feature into its own module, and so it couldn’t possibly have access to all of that information.
— 33:48
Well, this gives us the opportunity to flex some muscles of the Composable Architecture. Because features built in the library are all composed together, you get the instant ability for a parent feature to snoop on what is happening inside a child feature. No additional work is needed.
— 34:01
In particular, let’s add a new action to the domain of the first tab that represents that button being tapped: struct FirstTabFeature: Reducer { struct State {} enum Action { case goToInventoryButtonTapped } … }
— 34:14
And we’ll need to handle that action in the reducer, but there’s nothing this feature needs to do with it. It can just let it go by without doing anything because only the parent is interested in this action: func reduce( into state: inout State, action: Action ) -> Effect<Action> { switch action { case .goToInventoryButtonTapped: return .none } }
— 34:27
Now, it’s currently the case that this feature doesn’t need to do any logic for this action, but in the future it may. For example, it could track an analytics event if we were interested in seeing how many people like to switch to the inventory tab from this button as opposed to tapping the middle tab.
— 34:41
Next we can send this action in the view. To do so we need to construct a viewStore again, and although there currently isn’t any state to actually observe, ostensibly there will be some in the future, so let’s just use WithViewStore again: struct FirstTabView: View { let store: StoreOf<FirstTabFeature> var body: some View { WithViewStore( self.store, observe: { $0 } ) { viewStore in Button { viewStore.send(.goToInventoryButtonTapped) } label: { Text("Go to inventory tab") } } } } Referencing initializer ‘init(_:observe:content:file:line:)’ on ‘WithViewStore’ requires that ‘FirstTabFeature.State’ conform to ’Equatable’
— 35:13
But in order for this to compile we need our feature’s state to be Equatable : struct State: Equatable {}
— 35:21
Now things are compiling, and we have the super power of listening for whenever that action is sent into the system from the parent: case .firstTab(.goToInventoryButtonTapped):
— 35:43
In particular, when we see that action come in, we can switch the selected tab to the .inventory value: case .firstTab(.goToInventoryButtonTapped): state.selectedTab = .inventory return .none
— 35:50
And just like that the feature is back to working again. As your application gets more and more complex it can be really amazing to see how easy it is for parent features to instantly observe what is happening inside its child features.
— 36:06
And this child-to-parent communication basically comes for free, if we are willing to do the mechanical work of integrating all of our domains together. If you followed our series on “ Modern SwiftUI ” then you will know we talked a lot about parent-child communication patterns in vanilla SwiftUI. We showed that one of the best ways to facilitate communication is through what we called “delegate closures”, where the child domain exposes closures that the parent can override. But to get it right you have to take extra steps to make sure you always override the closure when creating the child object, otherwise your application will be subtly broken. So, it’s pretty nice to see we basically get this for free with little fuss.
— 36:44
There are even more benefits to integrating features together beyond parent-child communication patterns, which we will be getting into soon. Testing Brandon
— 36:51
So, now the application is back to working how it did when built in SwiftUI, but we have carved out a little feature for each of the main parts of the application, and integrated them together. We could even extract out each of the features into their own Swift module so that they can be built, tested, and even run in full isolation, all without building the entire application.
— 37:12
And it’s really cool to see that even though the tab features are all isolatable, they are still integrated together and it’s possible for the root-level feature to easily observe what is happening inside each child feature. In particular, we could instantly see when a button was tapped in the first tab and then layer on additional functionality at the root app feature.
— 37:32
Even better, this parent-child communication can be very easily tested. In fact, features built in the Composable Architecture are generally immediately testable, and it’s one of the super powers of the library.
— 37:48
Let’s write a quick test for this feature even though it isn’t that complicated right now. To start we can get a stub in place: import ComposableArchitecture import XCTest @testable import Inventory @MainActor class InventoryTests: XCTestCase { func testGoToInventory() async { } }
— 38:02
Although we don’t have any async work happening right now we are still going to leverage an async test and make the whole test suite @MainActor .
— 38:17
To test the feature we start by constructing a TestStore , which is like a regular Store , except when you send actions you must assert exactly how state changed. It also supports asserting how effects are executed and feed data back into the system, but we will get into that later: let store = TestStore( initialState: AppFeature.State(), reducer: AppFeature() )
— 38:44
This technically has a deprecation warning because the feature we are testing does not have Equatable state: ‘init(initialState:reducer:prepareDependencies:file:line:)’ is deprecated: State must be equatable to perform assertions.
— 39:03
Once the 1.0 of the library is released we will not allow creating test stores with non- Equatable state, but in the prerelease it is just a deprecation.
— 39:14
To fix this let’s make all of our State structs Equatable .
— 39:31
With that done, the next thing you can do is send an action to the store to emulate the user doing something, such as tapping the “Go to inventory” button inside the first tab: store.send(.firstTab(.goToInventoryButtonTapped))
— 39:49
And this .send is a little different from the one we’ve already encountered. First of all it is async so we must await it: await store.send(.firstTab(.goToInventoryButtonTapped))
— 39:56
And it takes a trailing closure that is handed a mutable value of the state of the feature before the action was sent, and it’s your job to mutate the value so that it matches the state after the action is sent.
— 40:14
Let’s just not do that now so that we can see what a test failure looks like: testGoToInventory(): A state change does not match expectation: … AppFeature.State( firstTab: FirstTabFeature.State(), inventory: InventoryFeature.State(), − selectedTab: .one, + selectedTab: .inventory, thirdTab: ThirdTabFeature.State() ) (Expected: −, Actual: +)
— 40:26
The library goes through great lengths to provide nice, succinct and readable test failure messages. Here it tells us exactly why the test failed. The state changed after sending the action but we did not assert on how it changed.
— 40:43
The fix is easy enough: await store.send(.firstTab(.goToInventoryButtonTapped)) { $0.selectedTab = .inventory }
— 40:51
…and now tests pass!
— 40:55
It is incredible how easy it is to write a test like this. We are proving that the parent and child domains are integrated correctly so that when a button is tapped in the child the parent properly reacts to it. And the tools in the library are even keeping you in check to make sure you assert on everything correctly.
— 41:46
If in the future more data was mutated during this action, we would get an instant test failure and it would be our responsibility to update the test so that we can prove we know how state changes in the feature. Even better, if we started executing effects, as we will in later episodes, we will also get failures if we are not asserting on that behavior too. The “delegate” pattern Stephen
— 42:11
So, this is all looking pretty great, but there is one thing that some people probably do not like about how we have had the app domain listen for events inside the first tab domain. The action it is listening for can be thought of as an “implementation detail” of the tab feature. Right now we have a button that says “Go to inventory”, but maybe in the future we have some kind of gesture to switch the tab. Or maybe an effect fires off to perform some work in the outside world, and then when it gets a response back we want to switch the tab.
— 42:39
The parent domain shouldn’t be responsible for listening to all of those different events to figure out when to mutate the selectedTab state. Instead, it would be far preferable if the child domain could clearly communicate when it wants the parent to switch to the inventory tab.
— 42:52
There is lightweight pattern to accomplish this, and it’s something we did in our open source word game, isowords , and we have used the pattern in client projects too. The Composable Architecture community has also independently stumbled upon this pattern and there are a few articles out there describing how to use it, which we have linked in the episode references at the bottom of the episode page.
— 43:13
The idea is for the child feature to carve out a little space in its Action enum for describing commands for the parent feature to interpret. We will do this by introducing a nested “delegate” action enum: enum Action { case goToInventoryButtonTapped case delegate(Delegate) enum Delegate { case switchToInventoryTab } }
— 43:45
We call these “delegate actions” because they are reminiscent of the delegate pattern from the days of UIKit. They are the actions that are appropriate for the parent feature to react to, and the parent can ignore all other actions inside the child domain.
— 43:58
Then, in the reduce method we can signal to the parent that we want them to switch to the inventory tab by synchronously sending the .switchToInventoryTab action: func reduce( into state: inout State, action: Action ) -> Effect<Action> { switch action { case .delegate: return .none case .goToInventoryButtonTapped: return .send(.delegate(.switchToInventoryTab)) } }
— 44:12
We are using the .send effect helper to immediately and synchronously send an action, and this is totally appropriate to do for delegate actions, but we do not recommend using synchronous effects like this in order to call other parts of your reducer. If you want to share logic to multiple parts of your reducer we recommend creating helper functions or methods in the reducer or in the state, and we have a detailed article about this very topic in our documentation. We have a link to it at the bottom of this episode page.
— 44:50
But, generally speaking, the child feature should never perform any logic in the .delegate actions, which is why we pattern match on it all at once and return .none . Only the parent feature should layer on additional logic for those actions.
— 45:02
And speaking of which, the parent feature can now layer on its logic when the .switchToInventoryTab action is sent from the child: case .firstTab(.delegate(.switchToInventoryTab)): state.selectedTab = .inventory return .none
— 45:16
This makes it clear as day that the child feature wants to switch to the inventory tab. The parent no longer needs to guess which actions to listen for in the child and hope that it chose the correct ones or hope that it listened for all of them.
— 45:27
And as time goes on the child feature may add more and more delegate actions to communicate to the parent, and with a small change we can let Swift’s amazing exhaustive enum switching capabilities keep us in check: case let .firstTab(.delegate(action)): switch action { case .switchToInventoryTab: state.selectedTab = .inventory return .none }
— 45:53
This will make it so that if a new delegate action is added we will get a compile time error letting us know we must handle it. This is a bit stronger than what we experienced with parent-child communication in vanilla SwiftUI, where we instead resorted to unimplemented callback closures to be notified at runtime.
— 46:10
And of course all of this is still testable, we just have to assert on how the communication mechanism works. So, when we send the .goToInventoryButtonTapped action, it is no longer true that state changes: await store.send(.firstTab(.goToInventoryButtonTapped))
— 46:24
Instead, the child sends a delegate action back into the system so that the parent can react to it. But, before asserting on that, let’s run tests to see what goes wrong: testGoToInventory(): A state change does not match expectation: … AppFeature.State( firstTab: FirstTabFeature.State(), inventory: InventoryFeature.State(), − selectedTab: .inventory, + selectedTab: .one, thirdTab: ThirdTabFeature.State() ) (Expected: −, Actual: +)
— 46:38
First, it’s no longer true that the state changes how we expect. But further, there is another error letting us know the store received an action that we didn’t assert on: The store received 1 unexpected action after this one: … Unhandled actions: [ [0]: .firstTab( .delegate(.switchToInventoryTab) ) ]
— 46:51
This is a great failure to have. It lets us know that there is something happening in the system that we haven’t asserted on. In particular, an action was sent back into the store, and we are forced to assert on that fact. To do this we use the receive method on TestStore : await store.receive( .firstTab(.delegate(.switchToInventoryTab)) ) Referencing instance method ‘receive(_:timeout:assert:file:line:)’ on ‘TestStore’ requires that ‘AppFeature.Action’ conform to ’Equatable’
— 47:12
But in order to do that we technically need our actions to be Equatable so that the TestStore can confirm that we are asserting on the correct action received.
— 47:20
Now, we highly recommend making your actions Equatable in order to strengthen your tests and prove that you really do know exactly your feature is evolving over time, but there is a small thing you can do to avoid that. It can be handy to get a basic test written before you are ready to dive all into equality.
— 47:41
And that is you can provide a predicate that determines whether or not the received action matches a condition. In that closure you can use guard case to pattern match against the action: await store.receive { guard case .firstTab( .delegate(.switchToInventoryTab) ) = $0 else { return false } return true } assert: { $0.selectedTab = .inventory }
— 48:26
It’s verbose, but it gets the job done and the test now passes.
— 48:34
If you wanted to only assert that some delegate action was received, you could even drop the .switchToInventoryTab : await store.receive { guard case .firstTab(.delegate) = $0 else { return false } return true } assert: { $0.selectedTab = .inventory }
— 48:47
And you can even use case paths to make the assertion a little shorter: await store.receive( (/AppFeature.Action.firstTab) .appending(path: /FirstTabFeature.Action.delegate) ) { $0.selectedTab = .inventory }
— 49:27
And if Swift had first class support for case paths someday you might be able to write this assertion like so: await store.receive(\.firstTab?.delegate) { $0.selectedTab = .inventory }
— 49:43
But unfortunately that is not the case.
— 49:51
So, these are some possibilities when making assertions on received actions, but like we said, we think the majority use case is to assert on the equality of the action received, and luckily actions are typically very easy to make Equatable because they should only ever hold simple data types.
— 50:06
So let’s make all of the our features’ actions Equatable .
— 50:33
And now this compiles: await store.receive( .firstTab(.delegate(.switchToInventoryTab)) ) { $0.selectedTab = .inventory }
— 50:42
And now tests pass!
— 50:46
So, while we did need to do some upfront work to integrate all of our features together, we immediately get some very simple communication mechanisms between the features, and it’s all testable. Comparing vanilla Brandon
— 50:56
It’s worth comparing what we have done here with what would need to be done in vanilla SwiftUI. Because while we did need to do upfront work to get the benefits, that work was at least mechanical and the compiler had our back to make sure the pieces fit together properly, and the TestStore also had our back making sure we account for everything going on in the system. But doing the same in vanilla SwiftUI is a little more ad hoc and nebulous.
— 51:25
For example, let’s start simple and say we had an AppModel observable object with a published field for selectedTab : import SwiftUI class AppModel: ObservableObject { @Published var selectedTab: Tab = .one }
— 51:53
And suppose we had a model for the first tab feature too with a single endpoint that is called when the “Go to inventory” button is tapped: class FirstTabModel: ObservableObject { func goToInventoryButtonTapped() { } }
— 52:22
Now we want to integrate these features together so that the parent can observe what is happening in the child and so that the child can communicate back to the parent. To do this we will hold onto the FirstTabModel as a property in the AppModel : class AppModel: ObservableObject { @Published var firstTab: FirstTabModel @Published var selectedTab: Tab init( firstTab: FirstTabModel, selectedTab: Tab = .one ) { self.firstTab = firstTab self.selectedTab = selectedTab } }
— 53:04
Then, in order for the child to actually communicate to the parent we need some kind of mechanism for the parent to hook into. The simplest way to do this us provide a closure that can be overridden from the parent that will be invoked in the child: class FirstTabModel: ObservableObject { var goToInventoryTab: () -> Void func goToInventoryTabTapped() { self.goToInventoryTab() } }
— 53:25
This allows the parent to be notified the moment something happens inside the child.
— 53:32
But, the ergonomics of this isn’t great. This is something we went really deep into during our episodes on “ Modern SwiftUI ”, but the short of it is this: By not having a default for the closure: var goToInventoryTab: () -> Void
— 53:43
…we force the parent to configure this immediately upon creating the child model, but in practice this is not always possible. Many times we need to bind to this closure at a later time.
— 53:56
But, if we provide a default like this: var goToInventoryTab: () -> Void = {}
— 53:59
…then we make it very easy to create and use the model without ever knowing there was a closure that should be customized. We will be blissfully unaware of this requirement and our features will be subtly broken.
— 54:10
And so this is why we recommend defaulting this closure to be what we call “unimplemented”, which is a tool that ships in our XCTest Dynamic Overlay library and comes transitively with the Composable Architecture: import XCTestDynamicOverlay … class FirstTabModel: ObservableObject { var goToInventoryTab: () -> Void = unimplemented( "FirstTabModel.goToInventoryTab" ) func goToInventoryTabTapped() { self.goToInventoryTab() } }
— 54:33
This will make it so that the closure doesn’t need to be provided upon initializing the model, but if it is ever invoked without being overridden you will get a visible purple runtime warning in Xcode and it will be a test failure when running in tests. That provides the perfect balance of ergonomics and safety.
— 55:10
So, with that done, we can have the parent tap into the child’s delegate callback closure so that it can perform the logic to switch the tab. However, because we are dealing with reference types here, we do have to be careful about retain cycles: init(firstTab: FirstTabModel) { self.firstTab = firstTab self.firstTab.goToInventoryTab = { [weak self] in self?.selectedTab = .inventory } }
— 55:48
But also, this isn’t fully correct. If we were to overwrite the child model with a whole new model: self.firstTab = FirstTabModel(…)
— 55:56
…then we would have to remember to again override the goToInventoryTab closure. So, that is why we recommend putting all of the binding logic into a dedicated method, which can even be private: private func bind() { self.firstTab.goToInventoryTab = { [weak self] in self?.selectedTab = .inventory } }
— 56:08
And we have to invoke this bind method both when the one field is set and when the AppModel is initialized: class AppModel: ObservableObject { @Published var firstTab: FirstTabModel { didSet { self.bind() } } init(…) { … self.bind() } … }
— 56:22
That’s what it takes to integrate two features together in vanilla SwiftUI. There are a number of steps in involved to do it right.
— 57:00
But there’s even more work to do if you want to test this. For example, suppose you wanted to test the FirstTabModel in isolation to make sure it does call the delegate closure, you could start like this: import XCTest @testable import Inventory class VanillaTests: XCTestCase { func testFirstTabModel() { let model = FirstTabModel() model.goToInventoryTabTapped() } }
— 57:44
And thanks to our choice to use an unimplemented closure for the delegate we get an immediate failure letting us know that there is more work to be done: Unimplemented: FirstTabModel.goToInventoryTab … Defined at: Inventory/Vanilla.swift:27
— 58:17
What we need to do is override the goToInventoryTab closure so that we can assert that it was actually called: func testVanillaFirstTabModel() { let model = FirstTabModel() model.goToInventoryTab = { <#???#> } model.goToInventoryTabTapped() }
— 58:27
But in order to assert that closure is called we have to set up an expectation, fulfill it, and wait for it: func testVanillaFirstTabModel() { let model = FirstTabModel() let expectation = self.expectation(description: "goToInventoryTab") model.goToInventoryTab = { expectation.fulfill() } model.goToInventoryTabTapped() self.wait(for: [expectation], timeout: 0) }
— 58:54
It works, but it’s a lot of steps to do something so simple. And technically we could have skipped the expectation and left the closure empty and it still would have passed: model.goToInventoryTab = { }
— 59:54
So there is a limitation to just how much the compiler or runtime has our back here.
— 59:59
Things are a little better if we test the integration between the two features instead of the child feature in isolation: func testAppModel() { let model = AppModel(firstTab: FirstTabModel()) model.firstTab.goToInventoryTabTapped() XCTAssertEqual(model.selectedTab, .inventory) }
— 1:00:49
That’s nice and succinct, but also it’s on us to make sure to assert against the selectedTab state. And maybe someday in the future more work will happen when switching to the inventory tab, such as mutating more state or even firing up asynchronous effects. It will be on us to make sure we update our tests to capture all of that new behavior. Next time: alerts and dialogs
— 1:01:51
So, we are seeing that if you want integrated features in vanilla SwiftUI in order to reap all the benefits that brings, you still have a number of steps to take to get it right, but in some sense there is even less help from the compiler and runtime to make sure you did everything correctly.
— 1:02:07
We think everything we have accomplished so far is pretty cool and even impressive, but its real purpose is to dip our toes into composing features together and communicating between features, because that concept is central to the Composable Architecture, and even more so with navigation.
— 1:02:27
By composing many features together, including all the destinations one can navigate to, we can have a very simple representation of something that is actually quite complex. No matter how many different places you can navigate to from a screen, and no matter how many layers deep you are in a navigation stack, you will have the ability to inspect what is happening in every layer and introduce new logic to integrate everything together. It’s honestly amazing to see. Stephen
— 1:02:52
But building those tools takes time, and to get the first hint at what those tools will look like eventually we will turn to one of the simplest forms of navigation: alerts. Alerts have the notion of presenting and dismissing, but they don’t manage logic and behavior on the inside. They simply show some buttons, and the user taps one of them to dismiss and optionally kick off an action.
— 1:03:14
Let’s start by showing how we can add an alert to this application using the tools that the Composable Architecture already comes with, and in fact these tools have been in the library since basically the beginning. And then we will see how we can greatly improve the tools, and that will set the stage for more complicated forms of navigation, such as sheets, popovers, and even drill-downs.
— 1:03:34
To get alerts into our application we are going to start showing a list of inventory items in the inventory tab, and have a button in the row that allows deleting the item, but first an alert will be shown for the user to confirm deletion…next time! References Modern SwiftUI: Navigation, Part 2 Brandon Williams & Stephen Celis • Dec 12, 2022 Our favorite way of managing parent-child communication in “modern” SwiftUI. Note We add more screens and more navigation to our rewrite of Apple’s Scrumdinger, including the standup detail view, a delete confirmation alert, and we set up parent-child communication between features. https://www.pointfree.co/collections/swiftui/modern-swiftui/ep216-modern-swiftui-navigation-part-2#t1471 TCA Action Boundaries Krzysztof Zabłocki • Aug 15, 2022 Krzysztof shows off a few patterns in the Composable Architecture, including “delegate” actions: Note To maintain our codebases for years, we must create boundaries across modules. Here’s my approach to doing that with The Composable Architecture. https://www.merowing.info/boundries-in-tca/ The "delegate" pattern in isowords Brandon Williams & Stephen Celis GitHub search results for DelegateAction in isowords, demonstrating the pattern of child-to-parent communication, where the child domain carves out a bit of its domain that makes it clear to the parent which actions are important to listen for. https://github.com/search?q=repo%3Apointfreeco%2Fisowords+DelegateAction&type=code Sharing logic with actions Brandon Williams & Stephen Celis Note There is a common pattern of using actions to share logic across multiple parts of a reducer. This is an inefficient way to share logic. Sending actions is not as lightweight of an operation as, say, calling a method on a class. Actions travel through multiple layers of an application, and at each layer a reducer can intercept and reinterpret the action. https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/performance/#Sharing-logic-with-actions Composable navigation beta GitHub discussion Brandon Williams & Stephen Celis • Feb 27, 2023 In conjunction with the release of episode #224 we also released a beta preview of the navigation tools coming to the Composable Architecture. https://github.com/pointfreeco/swift-composable-architecture/discussions/1944 Downloads Sample code 0222-composable-navigation-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 .