Video #68: Composable State Management: Reducers
Episode: Video #68 Date: Aug 5, 2019 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep68-composable-state-management-reducers

Description
Now that we understand some of the fundamental problems that we will encounter when building a complex application, let’s start solving some of them! We will begin by demonstrating a technique for describing the state and actions in your application, as well as a consistent way to apply mutations to your application’s state.
Video
Cloudflare Stream video ID: e4b41480cd75453c80ef9b30036c1c4c Local file: video_68_composable-state-management-reducers.mp4 *(download with --video 68)*
References
- Discussions
- Reduce with inout
- Elm: A delightful language for reliable webapps
- Redux: A predictable state container for JavaScript apps.
- Composable Reducers
- 0068-composable-state-management-reducers
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:06
The past few weeks we have explored the problem space of application architecture, and tried to uncover the root of what makes it so complicated. We ended up building a moderately complex application in the process, and although it was a bit of a toy example, it accented all of the pain points we encounter when building applications. In particular, we saw:
— 0:28
We want to be able to have complex app state that can be shared across many screens so that a mutation to one part of the state is reflected in the other screens instantaneously.
— 0:38
We want to be able to mutate the state in a consistent manner so that it’s obvious to newcomers to our code base how the data flows through the application.
— 0:50
We want to be able to build large, complex applications out of simple, composable units. Ideally we’d be able to build a component in full isolation, possibly even in its own module, and then later plug that component into a much bigger application.
— 1:05
We would like a well defined mechanism for executing side effects and feeding their results back into the application.
— 1:20
And finally we would like our architecture to be testable. Ideally we should be able to write tests with very little setup that allow us to describe a series of actions a user does in the app and then assert on the state of the app after those actions have been performed.
— 1:36
These are very important problems to solve because they allow us to scale our code base to handle many features and many developers working on the same app. Unfortunately, SwiftUI doesn’t solve these problems for us completely. It gives us many of the tools to solve it for ourselves, but it is up to us to take things the extra mile.
— 2:02
And so today we begin doing just that. We will introduce an application architecture that solves these problems. It’s opinionated in much the same way that SwiftUI is. It tells us exactly how we are supposed to model application state, tells us how mutations are applied to that state, tells us how to execute side effects and more. If we follow these prescriptions some really amazing benefits will start to pop up. And of course, the most important part, this architecture is entirely inspired by functional programming! We will draw inspiration from simple functions and function composition in order to understand how we can solve all of these problems.
— 2:41
We of course don’t claim that this architecture is a panacea and will solve all of your problems, and there will definitely be times where it seems that the problem you are working on simply does not fit this framework. However, we still feel that it’s worth exploring these ideas, and it can also be surprising how many problems can be solved with this architecture if you look at the problem from the right angle. Recap: our app so far
— 3:19
Let’s quickly remind ourselves what we came up with last time. We have your standard counting app, but with a few bells and whistles.
— 3:37
First we can drill into the counting screen, and hit the + and - buttons to increment and decrement the counter.
— 3:43
We can ask if the current counter value is prime, and if it is we can add or remove the prime to our list of favorites.
— 4:00
We can also ask for the nth prime, where n is the current counter value. Pressing this button actually fires off an API request and shows an alert when we get a response.
— 4:22
Then we can go into the favorites screen to see all of our favorites, but we can also remove any numbers that are no longer our favorites, and these changes are propagated across the various screens of our app.
— 4:49
Let’s also take a quick tour through the code.
— 4:54
We have our root AppState that holds all of the state in our application. class AppState: ObservableObject { @Published var count = 0 @Published var favoritePrimes: [Int] = [] @Published var loggedInUser: User? @Published var activityFeed: [Activity] = [] struct Activity { let timestamp: Date let type: ActivityType enum ActivityType { case addedFavoritePrime(Int) case removedFavoritePrime(Int) } } struct User { let id: Int let name: String let bio: String } }
— 5:10
Then we have our CounterView , which conforms to SwiftUI’s View protocol, and corresponds to the counter screen. struct CounterView: View { @ObservedObject var state: AppState @State var isPrimeModalShown = false @State var alertNthPrime: PrimeAlert? @State var isNthPrimeButtonDisabled = false var body: some View { VStack { HStack { Button("-") { self.state.count -= 1 } Text("\(self.state.count)") Button("+") { self.state.count += 1 } } Button("Is this prime?") { self.isPrimeModalShown = true } Button( "What is the \(ordinal(self.state.count)) prime?", action: self.nthPrimeButtonAction ) .disabled(self.isNthPrimeButtonDisabled) } .font(.title) .navigationBarTitle("Counter demo") .sheet(isPresented: self.$isPrimeModalShown) { IsPrimeModalView(state: self.state) } .alert(item: self.$alertNthPrime) { alert in Alert( title: Text( "The \(ordinal(self.state.count)) prime is \(alert.prime)" ), dismissButton: .default(Text("OK")) ) } } func nthPrimeButtonAction() { self.isNthPrimeButtonDisabled = true nthPrime(self.state.count) { prime in self.alertNthPrime = prime.map(PrimeAlert.init(prime:)) self.isNthPrimeButtonDisabled = false } } }
— 6:01
We also have the IsPrimeModalView , which corresponds to the modal view, where you can add or remove a prime from your favorites. struct IsPrimeModalView: View { @ObservedObject var state: AppState var body: some View { VStack { if isPrime(self.state.count) { Text("\(self.state.count) is prime 🎉") if self.state.favoritePrimes.contains(self.state.count) { Button("Remove from favorite primes") { self.state.favoritePrimes.removeAll( where: { $0 == self.state.count } ) self.state.activityFeed.append( .init( timestamp: Date(), type: .removedFavoritePrime(self.state.count) ) ) } } else { Button("Save to favorite primes") { self.state.favoritePrimes.append(self.state.count) self.state.activityFeed.append( .init( timestamp: Date(), type: .addedFavoritePrime(self.state.count) ) ) } } } else { Text("\(self.state.count) is not prime :(") } } } }
— 6:31
After that, the FavoritePrimesView , which is that list of favorite primes wherein you can remove a favorite at any time. struct FavoritePrimesView: View { @ObservedObject var state: AppState var body: some View { List { ForEach(self.state.favoritePrimes, id: \.self) { prime in Text("\(prime)") } .onDelete { indexSet in for index in indexSet { let prime = self.state.favoritePrimes[index] self.state.favoritePrimes.remove(at: index) self.state.activityFeed.append( .init( timestamp: Date(), type: .removedFavoritePrime(prime) ) ) } } } .navigationBarTitle("Favorite Primes") } }
— 6:54
Finally we have ContentView , which is a root view that brings the entire application together. struct ContentView: View { @ObservedObject var state: AppState var body: some View { NavigationView { List { NavigationLink( "Counter demo", destination: CounterView(state: self.state) ) NavigationLink( "Favorite primes", destination: FavoritePrimesView(state: self.state) ) } .navigationBarTitle("State management") } } }
— 7:11
And at the end of our playground, a bit of work to get it rendering in our live view. import PlaygroundSupport PlaygroundPage.current.liveView = UIHostingController( rootView: ContentView(state: AppState()) )
— 7:24
The entire application is only about 140 lines of code, and it’s all quite simple and straightforward! SwiftUI is doing some amazing stuff for us. However, that doesn’t mean there aren’t a few small problems with it that we should address.
— 7:38
For one, we have mutations littered throughout our views, which can obscure how data feeds into our application. Some mutations are hidden away in helper methods (which is only one layer of indirection, but you can imagine even more), while other actions contain several mutations and a lot of code inside a view.
— 8:21
We also would like a way of breaking views down so that they don’t have to take in the entire app state. Often we’ll have a view that only needs a very small part of app state and we’d like a very simple way of accomplishing that.
— 8:37
Because all of these mutations are bundled up in SwiftUI views, there’s no nice way to test them, and Apple’s given us no guidance on how we should.
— 8:49
This is the application so far. It’s quite simple but it does get at a lot of the problems that we need to solve when building a large application.
— 9:00
Let’s start chipping away at those problems. A better way to model global state
— 9:02
We’ll start with one of the simpler problems to fix: a better way to model our global state.
— 9:12
When we first built this app using earlier betas, we adopted the BindableObject protocol, which required us to use a class instead of a value type, declare a publisher that needs to get pinged any time state changes, and then manually tap into the didSet of each field in order to ping that publisher. This was a lot of extra boilerplate, and it really obscured the basic state of our application. Life comes at your fast when you work with early betas! Apple has already deprecated those APIs and introduced newer, sleeker ones in their place. We’ve updated our code to use these newer APIs and things are looking a lot better. class AppState: ObservableObject { @Published var count = 0 @Published var favoritePrimes: [Int] = [] @Published var activityFeed: [Activity] = [] @Published var loggedInUser: User? = nil struct Activity { let type: ActivityType let timestamp = Date() enum ActivityType { case addedFavoritePrime(Int) case removedFavoritePrime(Int) } } struct User { let id: Int let name: String let bio: String } } We’ll note that:
— 9:26
SwiftUI’s BindableObject protocol has been replaced with Combine’s ObservableObject protocol.
— 9:38
We no longer have to declare a publisher to get pinged when state updates: ObservableObject will automatically synthesize an objectWillChange publisher for us.
— 9:59
We also no longer have to ping the publisher manually: any property wrapped with @Published will automatically ping the publisher before it is updated.
— 10:10
It’s nice that Apple has already cleared away a lot of the fog introduced by earlier betas, but there’s still some room for improvement:
— 10:28
Our model layer is heavily coupled to the Combine framework: it conforms to a protocol from Combine and every property is wrapped with a Combine publisher. This introduces a dependency that we may not care about when interacting with our model layer outside of a SwiftUI view. In fact, such a dependency can prevent us from sharing this model with code that can’t import Combine, like server side Swift on Linux.
— 10:51
Even though things are much less noisy than before, we still have the noise of wrapping every property with @Published .
— 10:58
If we forget to wrap a property with @Published , SwiftUI will not be notified of its changes.
— 11:06
ObservableObject , like BindableObject , still requires us to use a class instead of a value type. Value types are great containers for state: they give us fine-grained control and guarantees over mutability.
— 11:19
One way to decouple our app state from Combine and benefit from value semantics is to convert AppState to be a struct and then create a very thin ObservableObject wrapper around it.
— 11:26
Converting our state to a struct is pretty straightforward: struct AppState { var count = 0 var favoritePrimes: [Int] = [] var activityFeed: [Activity] = [] var loggedInUser: User? = nil struct Activity { let type: ActivityType let timestamp = Date() enum ActivityType { case addedFavoritePrime(Int) case removedFavoritePrime(Int) } } struct User { let id: Int let name: String let bio: String } }
— 11:36
This is even simpler now! We were able to get rid of all of those @Published annotations and no longer have to conform to ObservableObject .
— 11:46
In order to regain this ObservableObject conformance, we have to wrap this struct in a class. We’ll call it Store : final class Store: ObservableObject { @Published var value: AppState init(initialValue: AppState) { self.value = initialValue } }
— 12:31
And we have some compiler errors to fix now, but before even doing that we can see that this class is overly specific. All Store wants to do is wrap a value type to provide a hook into its observer. It doesn’t need to know anything about AppState . So, let’s quickly make it generic over the value it wraps: final class Store<Value>: ObservableObject { @Published var value: Value init(initialValue: Value) { self.value = value } }
— 12:51
Now if we form something like: Store<AppState>
— 12:58
We will get an observable object that notifies that something changed as soon as any mutation is made to AppState . We have effectively consolidated all of our individual bits of state into one value.
— 13:10
That’s nice, but now we have a bunch of broken code. We can easily fix this code with a few search-and-replaces. First we replace this: @ObservedObject var state: AppState With this: @ObservedObject var store: Store<AppState>
— 13:28
Each view will now operate on the store of our app state, that way any mutations to the app state are properly notified to the SwiftUI views.
— 13:37
Next we need to replace all references to: self.state With: self.store.value
— 13:50
That will ensure that all views are properly reading from the store’s state and applying mutations to the store’s state.
— 13:56
We also have a few spots where we pass along state like this: (state: self.store.value) And that must be updated to pass the store instead: (store: self.store)
— 14:13
And finally, when we create our root content view we must pass along a store instead of an app state: rootView: ContentView(store: Store(value: AppState()))
— 14:28
And just like that, we have a compiling app and it works just the same. Functional state management
— 14:36
We’ve now decoupled app state from the Combine and SwiftUI frameworks and captured it in a value type instead of a class. We can also add new state willy-nilly and it doesn’t require any additional work. So already this is a pretty big win.
— 15:06
Let’s tackle another problem that’s a little more complicated than the previous: how do we want to handle state mutations so that there is a single, consistent way to perform mutation. Currently there really is no rhyme or reason to how we performed mutations to our state. We saw that it was quite easy to sprinkle in mutations in various action handlers and event callbacks, but after doing that a few times it became unclear how data flowed through the application. We’d like to have a single, consistent way to describe and perform mutations in our application so that no matter what file you are in you can easy see exactly how your application evolves with various user actions.
— 15:41
So let’s try to distill the essence of state mutations and come up with a description that we might be able to encode into an actual program. A state mutation is the act of taking your current state, and an event that occurred (such as a user tapping a button), and using both of those pieces of information to derive an all new state. For example, we could have our state where count is 0, and then a user action comes in that says that the user tapped the “+” button, and so we should produce a new state where count is 1. This is starting to sound like a function!
— 16:13
The only problem is that the “user action” event is ill-defined right now. Currently “user action” just means one of those action closures is executed in SwiftUI. We need to turn this concept into a proper data type so that we can actually operate on it. So, instead of doing mutations directly in the view, let’s say we create a data type to describe them.
— 16:37
Since there are many types of actions the user might perform, and an action can be any one of those types, an enum might be appropriate: enum CounterAction { case decrTapped case incrTapped }
— 17:10
Now we can cook up a function that takes a current piece of state and combines it will an action in order to get the updated state. It might look something like this: func counterReducer( state: AppState, action: CounterAction ) -> AppState { switch action { case .decrTapped: return AppState( count: state.count - 1, favoritePrimes: state.favoritePrimes, loggedInUser: state.loggedInUser, activityFeed: state.activityFeed ) case .incrTapped: return AppState( count: state.count + 1, favoritePrimes: state.favoritePrimes, loggedInUser: state.loggedInUser, activityFeed: state.activityFeed ) } }
— 18:24
This is pretty verbose for something so simple because we had to create all new state values for each mutation we wanted to do. We could fix the verbosity at the cost of a bit of boilerplate by doing a copy of our state: func counterReducer( state: AppState, action: CounterAction ) -> AppState { var copy = state switch action { case .decrTapped: copy.count -= 1 case .incrTapped: copy.count += 1 } return copy }
— 18:54
It’s definitely shorter, but every single reducer is going to have to do this copy dance. And if we ever accidentally mix up copy and state we are in for some really subtle bugs. But, let’s just roll with it now and we will address this problem in a bit.
— 19:35
Also, why did we call this counterReducer ? Seems like a strange name. The inspiration for this name comes from looking at the signature of the reduce function on arrays, for example: [1, 2, 3].reduce( <#initialResult: Result#>, <#nextPartialResult: (Result, Int) throws -> Result#> )
— 20:08
The Result value is like our state, it’s the value that will accumulate as we combine more and more integers into it. So the signature of the accumulator function that we feed into reduce on arrays is very similar to the signature of our counterReducer . And this is why we’ve named the function as we have.
— 20:23
But, how do we use this? We can create some state to play with, and then apply the counterReducer function a few times to see how it changes: let state = AppState() counterReducer(value: value, action: .incrTapped) print(state) // AppState( // count: 0, // favoritePrimes: [], // loggedInUser: nil, // activityFeed: [] // )
— 20:50
The state didn’t change because counterReducer returns a whole new app state, so really what we want to print is the value returned from the function. print(counterReducer(value: value, action: .incrTapped)) // AppState( // count: 1, // favoritePrimes: [], // loggedInUser: nil, // activityFeed: [] // )
— 21:05
And now we see that the count has incremented. If we want to then decrement the count, we can’t just do this: print(counterReducer(value: value, action: .decrTapped))
— 21:13
Since each pass of the counterReducer causes a whole new state to be created we are seeing that later applications of counterReducer aren’t changing previous states. In order to get that behavior we would need to nest like this: print( counterReducer( value: counterReducer( value: value, action: .incrTapped ), action:.decrTapped ) )
— 21:39
The reason we are seeing this nesting is because the counterReducer function only represents one single mutation at a time, and it takes you from one state to the next state. It has no ability to accumulate multiple mutations over time into one gigantic mutation of the app’s state. We need another mechanism in order to do that. But where should it live?
— 22:03
We already have this Store class that holds the current state of our application, and this is the state that we want to mutate so that all the views in our application get notified whenever something changes. So we could start by doing something silly, and replace our direct mutation of the store’s state with something that first invokes our reducer and then stuffs the new state into the store. Like in the decrement button: Button("-") { self.store.value = counterReducer( state: self.store.value, action: .decrTapped ) // self.store.state.count -= 1 }
— 22:49
And in the increment button: Button("+") { self.store.value = counterReducer( state: self.store.value, action: .incrTapped ) // self.store.state.count += 1 }
— 22:56
And everything compiles and works as it did before.
— 23:04
This is a lot more verbose, but there is at least one really nice thing about it. Rather than directly mutating the state inside a button’s action closure, we send the corresponding action value to the reducer, and let the reducer do all the necessary mutations. This is what it means to be declarative with user actions: we are describing what the user does rather than performing all of the messy, step-by-step mutations that are the result of that user action. And right now the mutation is quite simple, but you could imagine the reducer needing to do a bunch of work, and we have now siloed that work in the reducer. Ergonomics: capturing reducer in store
— 24:02
However, this is clearly not how we want to use reducers and the store. It’s going to be a lot of boilerplate to invoke our reducer directly each time and reassign the store’s state with the output of the reducer. It seems like we can move this boilerplate code into the store so that we only have to do it a single time, and then our call-sites in the views will be nice and tidy.
— 24:23
Imagine if we told the store that a user action had been invoked by just calling a simple method, and then it was the store’s responsibility to run the reducer. Here’s some pseudo code to show what that might look like to decrement our count: Button("-") { self.store.send(.decrTapped) } And to increment: Button("+") { self.store.send(.incrTapped) }
— 24:48
We are envisioning the name of this method to be send because that mimics the naming that the Combine framework uses for sending data to publishers.
— 24:56
And then the store can take care of running the reducer on the store’s current state using this action, and it could re-assign the state so that we get all the view updates. The store is starting to encapsulate more and more of the runtime behavior of the application.
— 25:11
So how can we do that?
— 25:14
To begin, since our store must now know about the state of our application and the kinds of actions that can mutate the state, we must introduce a new generic for the type of actions we recognize: final class Store<Value, Action>: ObservableObject { … func send(_ action: Action) { } }
— 25:34
Now inside this send method we want to invoke the reducer with our current state and then replace the current state with the new one that the reducer produced. However, we don’t have a reducer at our disposal, so it sounds like our store needs to hold onto a reducer: class Store<Value, Action>: ObservableObject { let reducer: (Value, Action) -> Value … init( initialValue: Value, reducer: @escaping (Value, Action) -> Value ) { self.value = value self.reducer = reducer } }
— 26:14
Now we can implement our send method: func send(_ action: Action) { self.value = self.reducer(self.value, action) }
— 26:29
With these changes we gotta fix some compiler errors. We gotta update our references to the Store to include the additional generic: @ObservedObject var store: Store<AppState, CounterAction>
— 26:50
And we now need to initialize our store with a reducer, so let’s do that: ContentView( store: Store(state: AppState(), reducer: counterReducer) )
— 27:29
Now things are compiling again, and everything works exactly as it did before. The only difference is that we have quarantined a small amount of the state mutation to our reducers, and we force ourselves to perform these mutations by invoking the send method on the store. We’re not allowed to just perform any mutation we want anywhere we want…we gotta package it up in an action and send it off to the reducer. Soon we will even be able to make the setter of the value property of Store private! And that will mean it’s impossible to mutate the app’s state unless it goes through a reducer. Ergonomics: in-out reducers
— 27:52
Now, we could keep going and extract more mutations out of our views and stuff them in our actions and reducers, but there’s another improvement we can make to our set up. Right now there are two annoying things about the way we have defined our reducers. First, we will have some boilerplate in each reducer where we create a copy of the state, do our mutations to the copy, and then return the copy. That boilerplate is error prone and cumbersome. And second this copying process is not going to be super efficient if we have a large app state. Doing a full app state copy every time a user performs an action and a reducer is run is simply not going to scale well.
— 28:35
Fortunately there is a trick we can employ to transform this seemingly inefficient function to an equivalent one that is more efficient. In fact, we talked about this trick on just our second episode of Point-Free where we discussed side-effects and Swift’s inout feature. In a nutshell, there is an equivalence between functions of the form: (A) -> A And ones of the form: (inout A) -> Void
— 29:03
You can think of any mutations that you do to your inout A as being a hidden output of the function, hence it behaves like a (A) -> A function.
— 29:18
This trick also works when your function has more than one input or output. For example, functions of the form: (A, B) -> (A, C) Are equivalent to functions of the form: (inout A, B) -> C In general, if a type parameter appears exactly once on both sides of the function arrow, you can remove it from the right side at the cost of introducing an inout argument on the left side.
— 29:40
So let’s apply this idea to our reducer signature: (Value, Action) -> Value
— 29:48
We can eliminate that Value on the right side of the arrow by introducing an inout : (inout Value, Action) -> Void
— 30:01
Now reducers in this form behave in exactly the same way as before, they are just a little more efficient. And in fact, the Swift standard library has a reduce overload with exact this signature. It was first proposed by our old pal Chris Eidhof and later introduced in Swift 3: [1, 2, 3].reduce( into: <#Result#>, <#updateAccumulatingResult: (inout Result, Int) throws -> ()#> )
— 30:33
This is exactly what we want to do with our counterReducer . Let’s start by altering its signature: func counterReducer(value: inout AppState, action: CounterAction) {
— 30:52
And then we’ll fix the compile errors by just performing mutations instead of copying and returning new values: func counterReducer(value: inout AppState, action: CounterAction) { switch action { case .decrTapped: value.count -= 1 case .incrTapped: value.count += 1 } }
— 31:09
We can use this reducer a similar manner as the other reducer: var state = AppState() counterReducer(value: &state, action: .incrTapped) counterReducer(value: &state, action: .decrTapped) Now it’s easy to just focus on what mutations are being performed in each action, and we have the added benefit of being even more efficient. But we have some compiler errors to fix.
— 31:56
First, let’s fix the signature of the reducer in the store: class Store<Value, Action>: ObservableObject { let reducer: (inout Value, Action) -> Void
— 32:03
And the initializer needs to be updated: init( initialValue: Value, reducer: @escaping (inout Value, Action) -> Void ) { self.value = value self.reducer = reducer }
— 32:07
And the send method now simplifies to: func send(_ action: Action) { self.reducer(&self.value, action) }
— 32:19
And that’s it! Our app now compiles, works exactly as it did before, and our code has become more ergonomic and even more performant. Sounds like a win-win situation. Moving more mutations into the store
— 32:56
But, we are still living in two worlds right now. We have some of our state being mutated through this nice new interface of sending actions to our store and letting it handle the mutation logic via our reducers, whereas some of our state is still being mutated directly inline in our views. Let’s fix that.
— 33:13
The IsPrimeModalView has the following mutations happening directly in a button action: Button("Remove from favorite primes") { self.store.value.favoritePrimes.removeAll( where: { $0 == self.store.value.count } ) self.store.value.activityFeed.append( .init( timestamp: Date(), type: .removedFavoritePrime(self.store.value.count) ) ) } … Button("Save to favorite primes") { self.store.value.favoritePrimes.append( self.store.value.count ) self.store.value.activityFeed.append( .init( timestamp: Date(), type: .addedFavoritePrime(self.store.value.count) ) ) } These mutations are responsible for adding or removing the current counter value to our list of favorite primes, as well as adding that event to our activity feed of things the user has done in the app, though we haven’t actually built out that screen yet.
— 33:32
Let’s approach these mutations just like we did the increment and decrement mutations. We’ll add new cases to our enum of actions: enum CounterAction { … case saveFavoritePrimeTapped case removeFavoritePrimeTapped }
— 33:53
And this creates a compile errors in our reducer because we now have new cases we need to handle: func counterReducer( value: AppState, action: CounterAction ) -> AppState { switch action { … case .saveFavoritePrimeTapped: fatalError() case .removeFavoritePrimeTapped: fatalError() } }
— 34:08
Now we can fill in these blanks easily enough, but before we even do that let’s address a weirdness. Why are we cramming logic for the modal into something called counterReducer and CounterAction ? Just as we have something called AppState , we should probably have some called AppAction that holds all of the app’s actions.
— 34:35
Let’s create a master AppAction enum that will hold all the actions for each screen: enum AppAction { case decrTapped case incrTapped case saveFavoritePrimeTapped case removeFavoritePrimeTapped }
— 34:53
But we’ve now lost some modularity. We are bundling our counter actions and modal actions in one place. It might be nicer to keep each set of actions in their own enums, where AppAction merely nests each sub-action. enum CounterAction { case decrTapped case incrTapped } enum PrimeModalAction { case saveFavoritePrimeTapped case removeFavoritePrimeTapped } enum AppAction { case counter(CounterAction) case primeModal(PrimeModalAction) }
— 35:31
Then our reducer can be an appReducer that works on this full action set by switching against each nested action: func appReducer(value: inout AppState, action: AppAction) -> Void { switch action { case .counter(.decrTapped): value.count -= 1 case .counter(.incrTapped): value.count += 1 case .primeModal(.saveFavoritePrimeTapped): fatalError() case .primeModal(.removeFavoritePrimeTapped): fatalError() } }
— 35:50
We still have to fill in these blanks, but let’s first fix some compiler errors. When sending actions to the store for the counter screen we must wrap the action in the new app action case: Button("-") { self.store.send(.counter(.decrTapped)) } Text("\(self.store.value.count)") Button("+") { self.store.send(.counter(.incrTapped)) }
— 36:06
Now that things are compiling, we can start moving our mutation logic out of the view and into the reducer: case .primeModal(.saveFavoritePrimeTapped): value.favoritePrimes.append(value.count) value.activityFeed.append( .init( timestamp: Date(), type: .addedFavoritePrime(value.count) ) ) case .primeModal(.removeFavoritePrimeTapped): value.favoritePrimes.removeAll(where: { $0 == value.count }) value.activityFeed.append( .init( timestamp: Date(), type: .removedFavoritePrime(value.count) ) ) Correction In the video we accidentally had the code for the .saveFavoritePrimeTapped and .removeFavoritePrimeTapped cases swapped, but the code snippet above is correct.
— 36:53
And then our button actions can simple send the corresponding action to the store: Button("Remove from favorite primes") { self.store.send(.primeModal(.removeFavoritePrimeTapped)) } … Button("Save to favorite primes") { self.store.send(.primeModal(.saveFavoritePrimeTapped)) }
— 37:35
And things are compiling and working just like before. Except for some upfront work of needing to rename and move some stuff around, it was quite easy to extract out that mutation into our store.
— 37:38
There’s another mutation we can easily extract. In the FavoritePrimesView we do the following in the onDelete handler: .onDelete { indexSet in for index in indexSet { self.store.value.favoritePrimes.remove(at: index) self.store.value.activityFeed.append( .init( timestamp: Date(), type: .removedFavoritePrime(prime) ) ) } }
— 37:52
Let’s follow the pattern we used for the previous two sets of mutations. This is a new screen, so let’s screen a new enum and add a case to AppAction : enum FavoritePrimesAction { case deleteFavoritePrimes(IndexSet) } enum AppAction { … case favoritePrimes(FavoritePrimesAction) }
— 38:24
We instantly get a compiler error in the reducer, so let’s address that: func appReducer(value: inout AppState, action: AppAction) -> Void { switch action { … case let .favoritePrimes(.deleteFavoritePrimes(indexSet)): for index in indexSet { let prime = value.favoritePrimes[index] value.favoritePrimes.remove(at: index) value.activityFeed.append( .init( timestamp: Date(), type: .removedFavoritePrime(prime) ) ) } } }
— 38:52
And finally we take the inline mutation in the view and replace it with code that simply sends the action to the store: .onDelete { indexSet in self.store.send( .favoritePrimes(.removeFavoritePrimes(at: indexSet)) ) }
— 39:14
We’ve now moved all of our inline mutations from button actions into our reducers and everything still works just as before.
— 39:35
And although we have moved a lot of our mutations into reducers and thus cleaned up our view quite a bit, there are still some mutations happening in the views. These are all of the local mutations that we decided didn’t need to be tracked on the global level of our application. Recall that local state in SwiftUI is modeled with @State , and that the CounterView has 3 instances of local state: @State var isPrimeModalShown = false @State var alertNthPrime: Int? @State var isNthPrimeButtonDisabled = false
— 39:45
There are a few places these values are mutated and it is done completely outside the purview of our reducers. This is unfortunate because it would be nice to unify all of our mutations into one cohesive package but dealing with alerts and modals is a little more complicated, so we are going to address that after we get the basics of reducers down. Till next time
— 40:05
We now have a very basic version of our architecture in place. We have a store class that is generic over a state type which represents the full state of our application and its generic over an action type that represents all of the user actions that can take place in our application.
— 40:34
The store class wraps a state value, which is just a simple value type, and this allows us to once and for all hook into the observer so that we can notify SwiftUI anytime a change is about to happen to our state.
— 40:38
The store class also holds onto a reducer, which is the brains of our application. It describes how to take the current state of the application, and an incoming action from the user, and produce a whole new state of the application that can be then rendered and displayed to the user. Already this little bit of work as solved 2 of the 5 problems we outlined at the beginning of this episode.
— 40:46
But, as cool as all of this is, we can go further. Let’s address the problem that is starting to develop in our appReducer . Right now it’s looking pretty hefty: one giant reducer that is handling the mutations for 3 different screens. This doesn’t seem particularly scalable. If we had two dozen screens are we really going to want a single switch statement that switches over every single action of 24 different screens? That’s not going to work.
— 41:17
We need to investigate ways of composing reducers into bigger reducers. How can break up that one big reducer into lots of little tiny ones that do one specific thing and then glue them together to form our master appReducer ? Let’s start to study that…next time! References Reduce with inout Chris Eidhof • Jan 16, 2017 The Swift standard library comes with two versions of reduce : one that takes accumulation functions of the form (Result, Value) -> Result , and another that accumulates with functions of the form (inout Result, Value) -> Void . Both versions are equivalent, but the latter can be more efficient when reducing into large data structures. https://forums.swift.org/t/reduce-with-inout/4897 Elm: A delightful language for reliable webapps Elm is both a pure functional language and framework for creating web applications in a declarative fashion. It was instrumental in pushing functional programming ideas into the mainstream, and demonstrating how an application could be represented by a simple pure function from state and actions to state. https://elm-lang.org Redux: A predictable state container for JavaScript apps. The idea of modeling an application’s architecture on simple reducer functions was popularized by Redux, a state management library for React, which in turn took a lot of inspiration from Elm . https://redux.js.org Composable Reducers Brandon Williams • Oct 10, 2017 A talk that Brandon gave at the 2017 Functional Swift conference in Berlin. The talk contains a brief account of many of the ideas covered in our series of episodes on “Composable State Management”. https://www.youtube.com/watch?v=QOIigosUNGU Side Effects Brandon Williams & Stephen Celis • Feb 5, 2018 We first discussed the idea of equivalence between functions of the form (A) -> A and functions (inout A) -> Void in our episode on side effects. Since then we have used this equivalence many times in order to transform our code into an equivalent form while improving its performance. Side effects can’t live with ’em; can’t write a program without ’em. Let’s explore a few kinds of side effects we encounter every day, why they make code difficult to reason about and test, and how we can control them without losing composition. https://www.pointfree.co/episodes/ep2-side-effects Downloads Sample code 0068-composable-state-management-reducers 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 .