SwiftRex: Best option for actions triggering other actions ?

Context

In some applications, certain events / actions are a signal (no pun intended) for other actions to be performed. I.e. something like :

Action --> Action(s) -/…eventually…/-> State change

Following on the recent discussions around AppLifecycleMiddleware and CoreLocationMiddleware, let’s imagine we have a didFinishLaunchingWithOptions action that was dispatched by the AppLifecycleMiddleware. My app now wants to initiate several side effects, like turning on location updates, remote notifications and sending a ping to a remote system.

Since those should technically happen as a result of the application finishing to launch :

  • How do I dispatch those other actions from, potentially, several different Middleware ?
  • Do I need to create some sort of a RoutingMiddleware ?
  • Is the EffectMiddleware a good candidate for this task ?
  • Something else, not Middleware related ?

Thanks, N.

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Comments: 28 (17 by maintainers)

Commits related to this issue

Most upvoted comments

Please try this on your Playground and tell me what you think. The version with custom operator can also be used, just omitted now for simplicity.

enum AppAction {
    case appLifecycle(AppLifecycle)
    case todo(Todo)

    var appLifecycle: AppLifecycle? {
        if case let .appLifecycle(value) = self { return value }
        return nil
    }
    var todo: Todo? {
        if case let .todo(value) = self { return value }
        return nil
    }
}

enum AppLifecycle {
    case started
    case deepLinkedInserting(String)
    case backgrounded
    case foregrounded
    case activated
    case deactivated

    var started: Void? {
        if case .started = self { return () }
        return nil
    }
    var deepLinkedInserting: String? {
        if case let .deepLinkedInserting(value) = self { return value }
        return nil
    }
    var backgrounded: Void? {
        if case .backgrounded = self { return () }
        return nil
    }
    var foregrounded: Void? {
        if case .foregrounded = self { return () }
        return nil
    }
    var activated: Void? {
        if case .activated = self { return () }
        return nil
    }
    var deactivated: Void? {
        if case .deactivated = self { return () }
        return nil
    }
}

enum Todo {
    case listAll
    case added(String)
    case removed(Int)

    var listAll: Void? {
        if case .listAll = self { return () }
        return nil
    }
    var added: String? {
        if case let .added(value) = self { return value }
        return nil
    }
    var removed: Int? {
        if case let .removed(value) = self { return value }
        return nil
    }
}

class BridgeMiddleware<InputActionType, OutputActionType> {
    private var bridges: [(InputActionType) -> OutputActionType?] = []

    init() { }

    func on<Value>(_ keyPathChecker: KeyPath<InputActionType, Value?>, dispatch outputAction: @escaping (Value) -> OutputActionType) -> BridgeMiddleware {
        bridges.append { receivedInputAction -> OutputActionType? in
            guard let value = receivedInputAction[keyPath: keyPathChecker] else { return nil }
            return outputAction(value)
        }
        return self
    }

    func on(_ keyPathChecker: KeyPath<InputActionType, Void?>, dispatch outputAction: OutputActionType) -> BridgeMiddleware {
        bridges.append { receivedInputAction -> OutputActionType? in
            guard receivedInputAction[keyPath: keyPathChecker] != nil else { return nil }
            return outputAction
        }
        return self
    }

    func handle(action: InputActionType) {
        bridges
            .compactMap { $0(action) }
            .forEach { print($0) }
    }
}

let mw = BridgeMiddleware<AppAction, AppAction>()
    .on(\.appLifecycle?.started, dispatch: .todo(.listAll))
    .on(\.appLifecycle?.started, dispatch: .todo(.removed(0)))
    .on(\.appLifecycle?.deepLinkedInserting, dispatch: { .todo(.added($0)) })

mw.handle(action: .appLifecycle(.started))
mw.handle(action: .appLifecycle(.foregrounded))
mw.handle(action: .appLifecycle(.activated))
mw.handle(action: .appLifecycle(.deepLinkedInserting("4")))

Looks like the BridgeMiddleware can’t be combined via the <> operator because it needs to conform to Middleware, so I added that to the code your provided above :

class BridgeMiddleware<GlobalInputActionType, GlobalOutputActionType, GlobalStateType>: Middleware {
    public typealias InputActionType = GlobalInputActionType
    public typealias OutputActionType = GlobalOutputActionType
    public typealias StateType = GlobalStateType
    
    private var bridges: [(InputActionType) -> OutputActionType?] = []
    private var output: AnyActionHandler<GlobalOutputActionType>? = nil

I also added the private variable output so it can be used in the handle method. In addition the handle method has to conform to what’s defined in the Middleware protocol spec, in addition to declaring the receiveContext method :

    func handle(action: GlobalInputActionType, from _: ActionSource, afterReducer _: inout AfterReducer) {
        handle(action: action)
    }

    func receiveContext(getState: @escaping GetState<GlobalStateType>, output: AnyActionHandler<GlobalOutputActionType>) {
        self.output = output
    }

And then I modified the handle method to dispatch the matching action :

    func handle(action: GlobalInputActionType) {
        bridges
            .compactMap { $0(action) }
            .forEach {
                output?.dispatch($0)
            }
    }

Works pretty well when I match the .didBecomeActive to CoreLocation’s .requestAuthorizationStatus :

let bridgeMiddleware = BridgeMiddleware<AppAction, AppAction, AppState>()
    .bridge(\.appLifecycle?.didBecomeActive --> .location(.request(.requestAuthorizationStatus)))
🕹 appLifecycle(AppLifecycleMiddleware.AppLifecycleAction.didBecomeActive)
🕹 location(CoreLocationMiddleware.LocationAction.request(CoreLocationMiddleware.RequestAction.requestAuthorizationStatus))
🕹 location(CoreLocationMiddleware.LocationAction.status(CoreLocationMiddleware.StatusAction.gotAuthzStatus(CoreLocationMiddleware.AuthzStatus(status: __C.CLAuthorizationStatus, accuracy: Optional(__C.CLAccuracyAuthorization)))))

Version with custom operator:

infix operator -->
struct ArrowMap<In, Value, Out> {
    let valueIn: KeyPath<In, Value?>
    let valueOut: (Value) -> Out?
}

func --> <In, Value, Out>(_ in: KeyPath<In, Value?>, _ out: @escaping (Value) -> Out?) -> ArrowMap<In, Value, Out> {
    .init(valueIn: `in`, valueOut: out)
}

func --> <In, Out>(_ in: KeyPath<In, Void?>, _ out: Out) -> ArrowMap<In, Void, Out> {
    .init(valueIn: `in`, valueOut: { out })
}

class BridgeMiddleware<InputActionType, OutputActionType> {
    private var bridges: [(InputActionType) -> OutputActionType?] = []

    init() { }

    func bridge<Value>(_ mapping: ArrowMap<InputActionType, Value, OutputActionType>) -> BridgeMiddleware {
        on(mapping.valueIn, dispatch: mapping.valueOut)
    }

    func bridge(_ mapping: ArrowMap<InputActionType, Void, OutputActionType>) -> BridgeMiddleware {
        on(mapping.valueIn, dispatch: mapping.valueOut)
    }

    func on<Value>(_ keyPathChecker: KeyPath<InputActionType, Value?>, dispatch outputAction: @escaping (Value) -> OutputActionType?) -> BridgeMiddleware {
        bridges.append { receivedInputAction -> OutputActionType? in
            guard let value = receivedInputAction[keyPath: keyPathChecker] else { return nil }
            return outputAction(value)
        }
        return self
    }

    func on(_ keyPathChecker: KeyPath<InputActionType, Void?>, dispatch outputAction: OutputActionType) -> BridgeMiddleware {
        bridges.append { receivedInputAction -> OutputActionType? in
            guard receivedInputAction[keyPath: keyPathChecker] != nil else { return nil }
            return outputAction
        }
        return self
    }

    func handle(action: InputActionType) {
        bridges
            .compactMap { $0(action) }
            .forEach { print($0) }
    }
}

let mw = BridgeMiddleware<AppAction, AppAction>()
    .bridge(\.appLifecycle?.started --> .todo(.listAll))
    .bridge(\.appLifecycle?.started --> .todo(.removed(0)))
    .bridge(\.appLifecycle?.deepLinkedInserting --> { .todo(.added($0)) })

mw.handle(action: .appLifecycle(.started))
mw.handle(action: .appLifecycle(.foregrounded))
mw.handle(action: .appLifecycle(.activated))
mw.handle(action: .appLifecycle(.deepLinkedInserting("4")))

It’s soooo interesting to see that ArrowMap is actually a simple flatMap on Optional (Kleisli arrow): ((In) -> Value?) -> ((Value) -> Out?) -> ((In) -> Out?)

struct ArrowMap<In, Value, Out> {
    let valueIn: KeyPath<In, Value?>
    let valueOut: (Value) -> Out?
}

This is how I do it today (using EffectMiddleware):

public enum AppLifecycleHandler {
    public typealias Dependencies = Void

    public static let middleware = EffectMiddleware<AppLifecycleAction, AppAction, AppState, Dependencies>.onAction { action, _, _ in
        switch action {
        case .start:
            // Here you should dispatch all `.start` actions that the app needs.
            return .sequence(
                .appDistribution(.start),
                .settings(.start),
                .discovery(.startDiscovery)
            )
        }
    }
}

This is more or less my proposal:

infix operator -->
struct ArrowMap<In, Out> {
    let valueIn: In
    let valueOut: Out
    fileprivate init(valueIn: In, valueOut: Out) {
        self.valueIn = valueIn
        self.valueOut = valueOut
    }
}

func --> <InputActionType, OutputActionType>(_ in: InputActionType, _ out: OutputActionType) -> ArrowMap<InputActionType, OutputActionType> {
    .init(valueIn: `in`, valueOut: out)
}

class BridgeMiddleware<InputActionType: Equatable, OutputActionType, StateType>: Middleware {
    private var bridges: [(InputActionType) -> OutputActionType?] = []
    private var getState: GetState<StateType>?
    private var output: AnyActionHandler<OutputActionType>?

    init() { }

    func receiveContext(getState: @escaping GetState<StateType>, output: AnyActionHandler<OutputActionType>) {
        self.getState = getState
        self.output = output
    }

    func bridge(_ mapping: ArrowMap<InputActionType, OutputActionType>) -> BridgeMiddleware {
        on(mapping.valueIn, dispatch: mapping.valueOut)
    }

    func on(_ expected: InputActionType, dispatch outputAction: OutputActionType) -> BridgeMiddleware {
        bridges.append { receivedInputAction -> OutputActionType? in
            guard expected == receivedInputAction else { return nil }
            return outputAction
        }
        return self
    }

    func handle(action: InputActionType, from dispatcher: ActionSource, afterReducer: inout AfterReducer) {
        guard let output = self.output else { return }
        bridges
            .compactMap { $0(action) }
            .forEach { output.dispatch($0, from: dispatcher) }
    }
}

This would be the usage:

    BridgeMiddleware<AppLifecycleAction, AppAction, AppState>()
        .bridge(.start --> .appDistribution(.start))
        .bridge(.start --> .settings(.start))
        .bridge(.start --> .discovery(.startDiscovery))

I haven’t tested that, I’m writing by heart and may not exactly work as expected, a TDD approach would be better. Also I don’t see how to extract values from inbound action and put it into the outbound action, because I need the Equatable to ensure it’s matching the expected type, and I can’t do that with associated values. Furthermore, actions need to be equatable which is super hard to achieve.

I am trying to think how KeyPath could be useful, but I still don’t know how to compare these enum values to ensure they are the same tree, otherwise ignoring the incoming action. Maybe something with Mirror could help.

So suggestions are welcome.