Reacting to state transitions

Hi there TCA peepz, :wave:

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.

2 Likes

Hey @ohitsdaniel, just wanted to drop a note to let you know that this can definitely be a handy operator, and it's something we are using in a current project. We try to use it sparingly (due to the problems you outlined), but it's really great for adding cross cutting concerns in a very lightweight way.

We are considering adding it to the library or possibly just creating a case study for it.

1 Like