Video #98: Ergonomic State Management: Part 1
Episode: Video #98 Date: Apr 13, 2020 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep98-ergonomic-state-management-part-1

Description
The Composable Architecture is robust and solves all of the problems we set out to solve (and more), but we haven’t given enough attention to ergonomics. We will enhance one of its core units to be a little friendlier to use and extend, which will bring us one step closing to being ready for production.
Video
Cloudflare Stream video ID: efeb76e7be3bbf377d079dc2bbe5b063 Local file: video_98_ergonomic-state-management-part-1.mp4 *(download with --video 98)*
Transcript
— 0:34
We have now spent many, many weeks building up our Composable Architecture from first principles. Its core design was motivated by trying to solve five problems that we found crucial for any application architecture to solve.
— 0:46
We then refined this design by addressing a couple memory leaks and a potential performance concern with how our architecture originally interfaced with SwiftUI. Tackling the latter issue provided us with an opportunity to enhance our architecture to be more adaptable to various situations, which allowed us to share core business logic across many platforms while refining the way each platform interacts with that shared logic.
— 1:07
We still have many, many things we want to explore in our architecture, but with these leaks and performance concerns addressed, we think it’s time to package things up to use in our applications. We could maybe even share it with the world as an open source project.
— 1:20
But before we do, we feel there is still some room for improvement. For one thing, we haven’t spent a ton of time on the ergonomics of the Composable Architecture. The core library is pretty small: less than a couple hundred lines of code. But even with such little surface area, I think we can take inspiration from earlier episodes of Point-Free as well as new Swift features to smooth out some of the rough edges around using these APIs. The architecture’s surface area
— 1:45
It starts with the definition of its core unit: a simple function that we call a “reducer”: public typealias Reducer<Value, Action, Environment> = (inout Value, Action, Environment) -> [Effect<Action>]
— 1:52
This single signature describes the entirety of an application’s logic:
— 1:56
It can mutate app state (which is captured by the Value generic) given an Action (typically a user action, like a button tap)
— 2:03
It’s also handed this Environment type, which holds all of our feature’s dependencies, like API clients, file clients, and anything else that needs to reach into the messy, outside world. And this environment is important, because…
— 2:15
…we must return an array of effects that will be run after our business logic has executed. This is what allows us to interact with the outside world, and feed information from the outside world back into our application.
— 2:27
If we took this signature and tried to write the logic for an entire application in a single function, things would quickly become unwieldy as state and actions grow. But lucky for us, functions are super composable and reducers are no exception. They can be broken down into smaller and smaller, easier-to-understand units and those units can then be glued back together to form the whole.
— 2:46
The way we are able to do this is with two powerful operations, combine and pullback . public func combine<Value, Action, Environment>( _ reducers: Reducer<Value, Action, Environment>... ) -> Reducer<Value, Action, Environment> { … } public func pullback< LocalValue, GlobalValue, LocalAction, GlobalAction, GlobalEnvironment, LocalEnvironment >( _ reducer: @escaping Reducer< LocalValue, LocalAction, LocalEnvironment >, value: WritableKeyPath<GlobalValue, LocalValue>, action: WritableKeyPath<GlobalAction, LocalAction?>, environment: @escaping (GlobalEnvironment) -> LocalEnvironment ) -> Reducer<GlobalValue, GlobalAction, GlobalEnvironment> { … }
— 2:53
The combine function allows us to combine a bunch of reducers that work on the same domain together into a single, mega-reducer that runs every given reducer under the hood, while pullback allows us to transform a reducer that works on a local domain into a reducer that works on a more global domain. Together they enable us to break our application logic down into a bunch of smaller reducers that can live in their own isolated modules. And then, at the application level we can take all of our smaller, more domain-specific reducers and pull them back to the global, app domain where they can be combined into a single reducer that powers our application.
— 3:34
Right below these functions we’ve another function, logging , which is what we call a “higher-order” reducer because it takes a reducer as input and returns a reducer as output. It can be used to add logging to any reducer. public func logging<Value, Action, Environment>( _ reducer: Reducer<Value, Action, Environment> ) -> Reducer<Value, Action, Environment> { … } Free functions
— 3:47
Before we move on to the rest of this file, let’s address the fact that these APIs might stand out a bit in that they’re all defined as free functions: “free” because they’re not trapped inside a type. We love free functions on Point-Free and think they’re nothing to fear. Swift even has great support for them! Still, they can feel a little out of place in everyday use, and they can introduce some awkward ergonomics.
— 4:11
To see how pullback and combine look in practice, we can hop over to our app target where we build our top-level, app reducer: let appReducer = combine( pullback( counterViewReducer, value: \AppState.counterView, action: /AppAction.counterView, environment: { $0.nthPrime } ), pullback( counterViewReducer, value: \AppState.counterView, action: /AppAction.offlineCounterView, environment: { $0.offlineNthPrime } ), pullback( favoritePrimesReducer, value: \.favoritePrimes, action: /AppAction.favoritePrimesState, environment: { ($0.fileClient, $0.nthPrime) } ) )
— 4:22
Function calls like these can be an odd sight to most Swift developers, and for some might even be an uncomfortable one! It can feel like pullback and combine have leaked out into the “global” namespace.
— 4:31
Function calls also become nested and difficult to read in Swift: your eyes need to navigate through layers of parentheses to get a handle on the order in which functions are called.
— 4:39
Now there are solutions to this nesting problem. In fact, way back in our very first episode of Point-Free, we saw that we could make free function calls much more readable with the help of an operator or two. We understand, however, that custom operators are a heated subject in the Swift community, and not everyone or every team will be comfortable introducing them to their code bases, so some time later, we introduced an alternative to operators in the form of “ Overture ,” a set of general purpose functions for functional programming. And it too aims to address this nesting problem, but without operators.
— 5:14
While the nesting of pullback s in a combine here isn’t so bad, nesting grows as we layer on additional functionality, as we may with higher-order reducers like logging .
— 5:25
In fact, if we hop over to our scene delegate, we’ll see this: rootView: ContentView( store: Store( initialValue: AppState(), reducer: with( appReducer, compose( logging, activityFeed ) ), … ) )
— 5:28
Here we see a bunch of nested function calls in order to add logging and another higher-order reducer, activityFeed , to our app reducer. And we also see this with and compose functions, which come from our Overture library! Without them, the reducer we pass to the store would look like this: logging(activityFeed(appReducer))
— 6:07
This particular example is relatively simple and so the nesting we’re avoiding is not the worst in the world, but you could imagine that a when you get to half a dozen higher-order reducers or so, the nesting becomes more and more unwieldy. logging(activityFeed(barEnhancer(fooEnhancer(appReducer))))
— 6:33
Overture is a perfectly fine solution for problems like these, but ideally it wouldn’t be a dependency of the Composable Architecture, nor a requirement to use our architecture in an ergonomic way.
— 6:44
So if not in free functions, where should we be defining these things? Swift seems to prefer putting logic in static functions and methods on types, so we might be tempted to move this logic into a Reducer extension: extension Reducer { } Non-nominal type ‘Reducer’ cannot be extended
— 7:00
But this won’t work because Reducer is just a type alias to a function signature, and functions are one example of what Swift calls “non-nominal” types, and non-nominal types cannot currently be extended with static functions or methods.
— 7:16
Thankfully we have another option here. There is a very simple way to create a nice namespace for functions, like reducers, and that is to wrap them in a struct. This is even a trick we’ve employed several times in the past on Point-Free.
— 7:27
Back in our series on composable randomness , we modeled randomness with a single, core unit: a function of the following signature: (inout RandomNumberGenerator) -> A
— 7:35
Like reducers, this function is super composable and transformable and had a ton of operations for building more complex generators.
— 7:43
For the sake of ergonomics, we wrapped this function in a type: struct Gen<A> { let run: (inout RandomNumberGenerator) -> A }
— 7:48
And we defined all of its operations as methods and static functions, which allowed us to chain transformations along in a way that felt natural in Swift.
— 7:56
This story repeated itself in our series on parsing , where we modeled the problem of parsing in a single function: (inout Substring) -> A?
— 8:04
Here too we had a super composable, transformable unit, so we wrapped it in a struct: struct Parser<A> { let run: (inout Substring) -> A? }
— 8:10
And housed all of its operations inside.
— 8:13
In fact, this story has even played out in the Composable Architecture! We first introduced the Effect type as a simple type alias to a function. typealias Effect<A> = (@escaping (A) -> Void) -> Void
— 8:21
But when we started exploring how effects were composable and transformable, we wrapped it in a struct, as well! // struct Effect<A> { // let run: (@escaping (A) -> Void) -> Void // }
— 8:32
Every time we’ve wrapped a function in a type, from Gen to Parser to Effect , it’s become more ergonomic in the process. Reducer as a struct
— 8:38
So here we are again with another function, the reducer. Let’s wrap it in a struct and see if we can once again improve the ergonomics around using them.
— 8:47
We’ll start by simply commenting out the type alias we’ve defined. // public typealias Reducer<Value, Action, Environment> = // (inout Value, Action, Environment) -> [Effect<Action>] And swapping in a struct with a single field. public struct Reducer<Value, Action, Environment> { let reducer: (inout Value, Action, Environment) -> [Effect<Action>] }
— 9:08
We’ll want to be able to instantiate these values from outside the Composable Architecture module, so let’s add a public initializer while we’re here. public init( _ reducer: @escaping ( inout Value, Action, Environment ) -> [Effect<Action>] ) { self.reducer = reducer }
— 9:33
Alright, so quite a few things have broken in this module. Let’s tackle them one at a time.
— 9:38
First we have a couple of errors in the combine function. public func combine<Value, Action, Environment>( _ reducers: Reducer<Value, Action, Environment>... ) -> Reducer<Value, Action, Environment> { return { value, action, environment in Cannot convert return expression of type ‘(_, _, _) -> _’ to return type ‘Reducer<Value, Action, Environment>’
— 9:47
Now that reducers are a type, it is no longer valid to create them using a bare closure. Instead, we can pass this function to the Reducer initializer as a trailing closure. public func combine<Value, Action, Environment>( _ reducers: Reducer<Value, Action, Environment>... ) -> Reducer<Value, Action, Environment> { return Reducer { value, action, environment in let effects = reducers.flatMap { $0.reducer(&value, action, environment) } Cannot call value of non-function type ‘Reducer<Value, Action, Environment>’
— 9:57
The second problem is that Reducer is now a struct and we can no longer call it directly as a function.
— 10:05
One option we have is to reach into its reducer field: public func combine<Value, Action, Environment>( _ reducers: Reducer<Value, Action, Environment>... ) -> Reducer<Value, Action, Environment> { return Reducer { value, action, environment in let effects = reducers.flatMap { $0.reducer(&value, action, environment) }
— 10:12
But we can also rely on a brand new feature of Swift 5.2: “callable values.” If a type defines a callAsFunction method, then it can be called directly, as if it were a function. extension Reducer { public func callAsFunction( _ value: inout Value, _ action: Action, _ environment: Environment ) -> [Effect<Action>] { self.reducer(&value, action, environment) } }
— 11:19
And now reducers can happily be invoked just as they were before. let effects = reducers.flatMap { $0(&value, action, environment) }
— 11:26
Our next errors are in pullback . public func pullback< LocalValue, GlobalValue, LocalAction, GlobalAction, LocalEnvironment, GlobalEnvironment >( _ reducer: @escaping Reducer<LocalValue, LocalAction>, value: WritableKeyPath<GlobalValue, LocalValue>, action: WritableKeyPath<GlobalAction, LocalAction?>, environment: @escaping (GlobalEnvironment) -> LocalEnvironment ) -> Reducer<GlobalValue, GlobalAction> { return { globalValue, globalAction, globalEnvironment in guard let localAction = globalAction[keyPath: action] else { return [] } let localEffects = reducer( &globalValue[keyPath: value], action: localAction, environment: environment(globalEnvironment) ) @escaping attribute only applies to function types Cannot convert return expression of type ‘(_, _, _) -> _’ to return type ‘Reducer<Value, Action, Environment>’ We no longer need the @escaping attribute because it only applies to function types and Reducer is now a struct, and the Reducer struct’s initializer has already captured the fact that the function has escaped.
— 11:37
We also need to wrap the function we’re returning in a Reducer initializer. public func pullback< LocalValue, GlobalValue, LocalAction, GlobalAction, LocalEnvironment, GlobalEnvironment >( _ reducer: Reducer<LocalValue, LocalAction>, value: WritableKeyPath<GlobalValue, LocalValue>, action: WritableKeyPath<GlobalAction, LocalAction?>, environment: @escaping (GlobalEnvironment) -> LocalEnvironment ) -> Reducer<GlobalValue, GlobalAction> { return .init { globalValue, globalAction, globalEnvironment in guard let localAction = globalAction[keyPath: action] else { return [] } let localEffects = reducer( &globalValue[keyPath: value], localAction, environment(globalEnvironment) )
— 11:45
And we need to make those same changes to the logging higher-order reducer : public func logging<Value, Action, Environment>( _ reducer: Reducer<Value, Action, Environment> ) -> Reducer<Value, Action, Environment> { return .init { value, action, environment in
— 11:57
Just a few errors left, and they’re in the Store , which is the runtime class that powers our architecture.
— 12:00
Stores are initialized with a reducer, so we need to drop the @escaping requirement once again: public init<Environment>( initialValue: Value, reducer: Reducer<Value, Action, Environment>, environment: Environment ) {
— 12:06
In the body of the initializer we wrap the given reducer with a new one in order to “erase” its environment, something we motivated back in our episodes on modular dependency management . This wrapper needs to invoke Reducer ’s initializer, as well: ) { self.reducer = Reducer { value, action, environment in reducer(&value, action, environment as! Environment) } self.value = initialValue self.environment = environment }
— 12:20
And finally, stores have a scope method, which can transform stores on global state and global actions into stores on more local state and local actions. This operation is exactly what allowed us to isolate our app’s views into their own modules.
— 12:34
On the inside, it creates a new store that needs a new reducer, so we need to invoke Reducer.init once more: public func view<LocalValue, LocalAction>( value toLocalValue: @escaping (Value) -> LocalValue, action toGlobalAction: @escaping (LocalAction) -> Action ) -> Store<LocalValue, LocalAction> { let localStore = Store<LocalValue, LocalAction>( initialValue: toLocalValue(self.value), reducer: .init { localValue, localAction, _ in
— 12:44
And just like that the Composable Architecture is building again! Reducer methods
— 12:47
Alright, now that we have a natural namespace for our reducers, all of our composition functions can be updated to be more ergonomic as static functions and methods:
— 13:10
Let’s start with combine . It’s a free, variadic function that combines any number of given reducers. We can nest it in a Reducer extension as a static function and get rid of the generics, since they’re now implicit in the type: extension Reducer { public static func combine( _ reducers: Reducer... ) -> Reducer { return Reducer { value, action, environment in let effects = reducers.flatMap { $0(&value, action, environment) } return effects } } } And nothing in the body of this function needs to change.
— 14:05
We make this a static function instead of a method because it isn’t clear if there is one particular reducer that we can dot-chain onto. Instead, we have a whole list of reducers that are all equally important.
— 14:32
Next up: pullback . Because pullback takes a single reducer as input, instead of creating another static function, we can maybe better express its functionality as a method that enhances the current self reducer.
— 14:42
We can also move pullback into our Reducer struct, eliminate the reducer argument, and instead reduce directly on self : extension Reducer { … public func pullback<GlobalValue, GlobalAction, GlobalEnvironment>( value: WritableKeyPath<GlobalValue, Value>, action: WritableKeyPath<GlobalAction, Action?>, environment: @escaping (GlobalEnvironment) -> Environment ) -> Reducer<GlobalValue, GlobalAction, GlobalEnvironment> { return .init { globalValue, globalAction, globalEnvironment in guard let localAction = globalAction[keyPath: action] else { return [] } let localEffects = self( &globalValue[keyPath: value], localAction, environment(globalEnvironment) ) And we were even able to get rid of the 3 Local generics by leveraging the Value , Action , and Environment on Reducer .
— 15:57
Let’s move on to the logging higher-order reducer. It’s a function that takes a single reducer as input. So just like pullback , it should be another candidate for being defined as a method that operates on self : extension Reducer { … public func logging() -> Reducer { return .init { value, action, environment in let effects = self.reduce( into: &value, action: action, environment: environment )
— 16:38
Alright! Everything is building and we’ve moved all of our operations for composing and transforming reducers into the new Reducer type.
— 16:45
But before moving on, while we’re here making improvements to the architecture’s ergonomics, let’s generalize the logging function now that it has access to the environment. We currently use the Swift standard library’s print function, but there are other methods of logging we may want to invoke.
— 17:22
So what if we could customize the kind of printer used under the hood by describing how to pluck a printer out of an environment? public func logging( printer: (Environment) -> (String) -> Void, ) -> Reducer { return .init { value, action, environment in let effects = self.reduce( into: &value, action: action, environment: environment ) let newValue = value let print = printer(environment) return [ .fireAndForget { print("Action: \(action)") print("Value:") var dumpedNewValue = "" dump(newValue, to: &dumpedNewValue) print(dumpedNewValue) print("---") } ] + effects
— 18:38
And now all of the printing in this function goes through a printer that we extract from the environment.
— 18:47
If it seems a little heavy-handed to require us to always hold a printer in our environment when the Swift standard library’s print function is at-hand, we can default this argument to ignore the environment and reach directly for that function. public func logging( printer: (Environment) -> (String) -> Void = { _ in { print($0) } } ) -> Reducer {
— 19:16
And this shows just how easy it is to enhance higher-order reducers with even stronger capabilities by allowing us to describe requirements we have of our environment. Updating the app’s modules
— 19:32
Everything is building in the Composable Architecture. Of course, whenever we make a change to our architecture, we have a bunch of work to do in our app’s modules to get everything building again. So let’s update each module at a time and see how the ergonomics have changed.
— 20:05
If we switch over to work on the favorite primes module, we’ll find that, funnily enough, everything still builds! This is because we don’t have a single reference to Reducer in this file, and that’s because we define favoritePrimesReducer directly as a function: public func favoritePrimesReducer( state: inout FavoritePrimesState, action: FavoritePrimesAction, environment: FavoritePrimesEnvironment ) -> [Effect<FavoritePrimesAction>] {
— 20:27
Instead, let’s define this as a Reducer value. It’s just a matter of invoking an initializer with a trailing closure: public let favoritePrimesReducer = Reducer< FavoritePrimesState, FavoritePrimesAction, FavoritePrimesEnvironment > { state, action, environment in
— 21:05
That seems to be all that needed to change in this module. We can even hop over to this module’s playground to make sure everything’s still working.
— 21:31
Let’s move onto the prime modal.
— 21:38
It too still builds, but also needs to instantiate its reducer as a value: public let primeModalReducer = Reducer< PrimeModalState, PrimeModalAction, Void > { state, action, _ in
— 22:16
One thing to note is that the prime modal reducer executes no side effects, and because of this we gave it a Void environment, which signifies an environment that doesn’t hold anything meaningful, and therefore doesn’t need any dependencies to do its job
— 22:29
The counter module is a bit more complicated, and is no longer in building order. But before we tackle those errors, let’s update the counter reducer to be a Reducer value: public let counterReducer = Reducer< CounterState, CounterAction, CounterEnvironment > { state, action, environment in
— 23:00
So why isn’t the module building? It’s responsible for composing a couple reducers together using pullback and combine , and it’s still using the old, free function interface, which no longer exists: public let counterViewReducer = combine( pullback( counterReducer, value: \CounterViewState.counter, action: /CounterViewAction.counter, environment: { $0 } ), pullback( primeModalReducer, value: \.primeModal, action: /CounterViewAction.primeModal, environment: { _ in () } ) ) Use of unresolved identifier ‘combine’ Use of unresolved identifier ‘pullback’
— 23:18
The combine function now lives statically on Reducer , so we can call out to that instead. And we can call pullback as a method directly on each reducer value. public let counterViewReducer = Reducer.combine( counterReducer.pullback( value: \CounterViewState.counter, action: /CounterViewAction.counter environment: { $0 } ), primeModalReducer.pullback( value: \.primeModal, action: /CounterViewAction.primeModal, environment: { _ in () } ) )
— 23:46
And that’s all that needed to change in the counter module!
— 23:49
The main app target is also not quite building yet, and that’s because, like the counter module, it is responsible for pulling back and combining some reducers, and those calls need to be updated to the new interface: let appReducer Reducer< AppState, AppAction, AppEnvironment > = .combine( counterViewReducer.pullback( value: \AppState.counterView, action: /AppAction.counterView environment: { $0.counter } ), counterViewReducer.pullback( value: \AppState.counterView, action: /AppAction.offlineCounterView, environment: { $0.offlineNthPrime } ), favoritePrimesReducer.pullback( value: \.favoritePrimes, action: /AppAction.favoritePrimes environment: { $0.favoritePrimes } ) )
— 24:34
The activityFeed higher-order reducer also has some errors. We can drop the @escaping and wrap the reducer it returns using the Reducer initializer. func activityFeed( _ reducer: Reducer<AppState, AppAction, AppEnvironment> ) -> Reducer<AppState, AppAction, AppEnvironment> { return .init { state, action, environment in
— 24:57
But even better, we can move this logic into the Reducer type, as we did with the higher-order logging function.
— 25:03
We’ll just extend Reducer conditionally where the value, action, and environment are in our app domain, and then define activityFeed as a computed property that enhances self . extension Reducer where Value == AppState, Action == AppAction, Environment == AppEnvironment { func activityFeed() -> Reducer { .init { state, action, environment in … return self(&state, action, environment) } } }
— 25:47
We’re down to just a couple build errors where we create the root view and pass it a store: window.rootViewController = UIHostingController( rootView: ContentView( store: Store( initialValue: AppState(), reducer: with( appReducer, compose( logging, activityFeed ) ), … ) ) ) Use of unresolved identifier ‘logging’ Use of unresolved identifier ‘activityFeed’
— 25:58
Both logging and activityFeed have moved to the reducer type, which means we no longer have to depend on Overture’s with and compose functions. Instead, we can chain these properties along, directly on appReducer , we just need to make sure we get the order right to ensure that the activity feed is logged: window.rootViewController = UIHostingController( rootView: ContentView( store: Store( initialValue: AppState(), reducer: appReducer .activityFeed() .logging(), … ) ) )
— 26:15
This reads really nicely! We can easily see that we are enhancing our app reducer with an activity feed and then logging.
— 26:30
Even better, using methods makes it easier to localize these changes to just the reducers we care about. Say we didn’t want to log every action in the entire app, but instead we only cared about the counter reducer for right now. Well, we can remove .logger from here, and instead attach it to the end of the counter reducer: public let counterReducer = Reducer< CounterState, CounterAction, CounterEnvironment > { state, action, environment in … } .logging()
— 26:49
Now we will only get logging for actions that come through the counter reducer. This can be very powerful for localizing functionality to a particular reducer. Till next time
— 26:58
There’s another thing we can do to improve the ergonomics of our architecture, and that’s in the view layer. Right now, a view holds onto a view store that contains all of the state it cares about to render itself, and in order to access this state, we dive through the view store’s value property. Let’s see if we can fix that…next time! Downloads Sample code 0098-ergonomic-state-management-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 .