Hi there TCA peepz,
This is just an idea that I wanted to run past you all. Currently, reducers only allow us to react to actions sent into them, however, it would be nice to be able to react to state changes directly. Ideally, we would have some kind of didSet equivalent as part of the reducer. We could extend Reducer with a onChange function which would allows us to trigger effects after a state transition.
import ComposableArchitecture
extension Reducer {
func onChange<Value>(
of value: @escaping (State) -> Value,
isEqual: @escaping (Value, Value) -> Bool,
_ onChange: @escaping (Value, Value, Environment) -> Effect<Action, Never>
) -> Reducer<State, Action, Environment> {
var initialValue: Value!
return .combine(
Reducer { state, _, _ in
initialValue = value(state)
return .none
},
self,
Reducer { state, _, environment in
let currentValue = value(state)
guard !isEqual(currentValue, initialValue) else {
return .none
}
return onChange(initialValue, currentValue, environment)
}
)
}
func onChange<Value: Equatable>(
of value: @escaping (State) -> Value,
_ onChange: @escaping (Value, Value, Environment) -> Effect<Action, Never>
) -> Reducer<State, Action, Environment> {
self.onChange(of: value, isEqual: ==, onChange)
}
}
Let's take a look at an example.
struct Content: Equatable {}
struct ViewState: Equatable {
enum State: Equatable {
case none
case loading
case error(EquatableError)
case some(Content)
}
var state: State
}
enum Action: Equatable {
case viewAppeared
case loadContent
case loadContentCompleted(Result<Content, EquatableError>)
}
struct Environment {}
let reducer = Reducer<ViewState, Action, Environment> { state, action, environment in
switch action {
case .viewAppeared:
// trigger the initial load content, if state is .none
case .loadContent:
// load new content
case let .loadContentCompleted(result):
// assign new content to state variable
}
}
.onChange(
of: \.state,
{ previous, current, environment in
guard case .none = current else {
return .none
}
return Effect(value: .loadContent)
}
)
In this example, whenever the state changes to .none, we trigger an effect to load the content. We don't need to know which explicit action sets the state to .none as we only want to react to the resulting state transition.
Problems
.onChange
would not work inside pullback reducers, if the observed state change happens in the layer above. In other words, onChange observations would only be allowed in the 'source' state of the observed value, not in any states derived from the source state.