Lately, I’ve been working on a project that employs a Redux-inspired state management architecture built using Swift and SwiftUI. Of course, because of the Redux pattern we employ, we have lots of reducers with the signature (state, action) -> state
.
All of our states and sub-states are held in Swift structs. The out-of-the-box, value-type semantics of structs in Swift work nicely with the focus Redux has on pure state transformations and immutable data. In fact, the value semantics of structs make immutability and deep copies the default mode of operation, as opposed to Redux and JavaScript, which require external immutability libraries or Typescript to ensure type safety.
With that said, there is still a certain amount of noise that tends to crop up in reducer functions because of how structs work. For instance:
typealias Reducer<State, Action> = (State, Action) -> State
struct CounterState {
var count = 0
}
enum CounterAction {
case increment
case decrement
}
let CounterReducer: Reducer<CounterState, CounterAction> = { state, action in
switch action {
case .increment:
break
case .decrement:
break
}
}
In either case of the switch, we need to return a new counter state with the previous count either incremented or decremented. We can’t directly mutate state, though. The following is a compile error:
case .increment:
state.count += 1 // won't compile
This is because state is bound as a let
constant in the closure scope and because even var
fields in a let
bound struct cannot be mutated.
Instead, we could set up each branch as follows:
case .increment:
return CounterState(count: state.count + 1)
This is a straightforward approach that underscores the immutability we’re working with. But if state struct were to grow in size, we would end up copying lots of fields that weren’t updated in each branch, adding lots of noise to the code.
Another attempt is like so:
let CounterReducer: Reducer<CounterState, CounterAction> = { state, action in
var state = state
switch action {
case .increment:
state.count = state.count += 1
case .decrement:
state.count = state.count -= 1
}
return state
}
Now each branch is only concerned with updating the fields associated with it.
It might feel odd to have that var
at the top of this (and any other) reducers. Swift actually has a way to achieve the exact same semantics with two fewer lines of code. All we need to do is change the reducer type signature to the following:
typealias Reducer<State, Action> = (inout State, Action) -> Void
Now, our reducer looks like this:
let CounterReducer: Reducer<CounterState, CounterAction> = { state, action in
switch action {
case .increment:
state.count = state.count += 1
case .decrement:
state.count = state.count -= 1
}
}
It’s exactly the same, minus the extra lines. This is because inout parameters behave like they’re bound as var
s instead of let
s. If whatever is passed to this function is a struct, Swift deals with the details of creating a var
copy with the closure scope and then copying any updates back out to the call site.
This approach is clean and straightforward, but any developer expecting a Redux-inspired approach might be very surprised by the (inout State, Action) -> Void
function signature. One of the core “rules” of Redux is that reducers shouldn’t mutate their arguments. Of course, the way Swift works, it isn’t really mutating arguments, but it still looks odd and adds mental overhead to writing a reducer.
So we want an approach that leaves our (state, action) -> state
type signature intact, but we’d like a concise way to directly return all of a state struct, with some small update applied (kind of like the “splat” operator in JavaScript).
The following protocol and default conformance provides such a concise method:
public typealias Update = (inout T) -> Void
public protocol Updateable {
func update(with block: @escaping Update) -> Self
}
extension Updateable {
public func update(with block: @escaping Update = { _ in }) -> Self {
var val = self
block(&val)
return val
}
}
Then we simply change the declaration of our CounterState to be:
struct CounterState: Updateable {
var count = 0
}
And our reducer becomes:
let CounterReducer: Reducer<CounterState, CounterAction> = { state, action in
state.update() {
switch action {
case .increment:
$0.count = state.count + 1
case .decrement:
$0.count = state.count - 1
}
}
}
This pattern can be applied to other functions besides reducers. For instance:
func getTestData(with block: @escaping Update = { _ in }) -> TestData {
TestData(
// some sane defaults
).update(with: block)
}
Now developers can easily grab a TestData struct set up with whatever the sane defaults are, but updated to reflect the needs of the particular test.