Skip to content

Navigation Menu

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Triggering effects from child #3648

arkulpaKonstantin started this conversation in Ideas
Discussion options

We are currently working on an App that needs to trigger some functionality in the root reducer from many child reducers and child of child of child ... reducers.

To handle this we used to listen to all the child actions and then trigger the functionality, but as that got really confusing and a lot of boilerplate for every new child action we added something like the dismiss functionality for this case.

I thought I would share this and suggest to make it more general with some hashable ID or so.

One issue we ran into is that in tests the effect is expected to finish. But one can not for now do that so we have to use skipInFlightEffects.

Here is our code. (As you may guess the functionality we need has to do with the parent reducer doing a restart 😅)
The effect:

import ComposableArchitecture

/// An effect that restarts the application.
///
/// Execute this in the effect returned from a reducer in order to restart the whole application:
///
/// ```swift
/// @Reducer
/// struct ChildFeature {
///   struct State { /* ... */ }
///   enum Action {
///     case logoutButtonTapped
///     // ...
///   }
///   @Dependency(\.restart) var restart
///   var body: some Reducer<State, Action> {
///     Reduce { state, action in
///       switch action {
///       case .logoutButtonTapped:
///         return .run { _ in await self.restart() }
///       // ...
///       }
///     }
///   }
/// }
/// ```
///
/// This operation works by finding the nearest parent feature that uses the `.restartAction(_ restartAction: Action)` operator and
/// sending the specified restart action to that parent reducer.
///
/// > Warning: The `@Dependency(\.restart)` tool only works for features that are children of parents using
/// > the `restartAction` operator. If no parent feature is found calling the restart has no effect.
/// >
/// > If you are testing a child feature in isolation that makes use of `@Dependency(\.restart)`
/// > then you will need to override the dependency. You can even mutate
/// > some shared mutable state inside the `restart` closure to confirm that it is indeed invoked.
public struct RestartEffect: Sendable {
    var restart: (@MainActor @Sendable () -> Void)?

    @MainActor
    public func callAsFunction() async {
        self.restart?()
    }
}

extension RestartEffect {
    public init(_ restart: @escaping @MainActor @Sendable () -> Void) {
        self.restart = restart
    }
}

private enum RestartEffectKey: DependencyKey {
    static let liveValue = RestartEffect()
    static let testValue = RestartEffect()
}

extension DependencyValues {
    /// An effect that restarts the application.
    ///
    /// See the documentation of ``RestartEffect`` for more information.
    public var restart: RestartEffect {
        get { self[RestartEffectKey.self] }
        set { self[RestartEffectKey.self] = newValue }
    }
}

The reducer modifier:

import Combine
import ComposableArchitecture

public struct RestartEffectId: Hashable, Sendable {
    public init() {}
}

public struct RestartReducer<Base: Reducer>: Reducer {
    @usableFromInline
    let base: Base

    @usableFromInline
    let restartAction: Base.Action

    @usableFromInline
    init(base: Base, restartAction: Base.Action) {
        self.base = base
        self.restartAction = restartAction
    }

    @inlinable
    public func reduce(
        into state: inout Base.State, action: Base.Action
    ) -> Effect<Base.Action> {
        let subject = PassthroughSubject<Base.Action, Never>()

        let restartEffect = Effect<Base.Action>.publisher {
            subject
        }
        .cancellable(id: RestartEffectId(), cancelInFlight: true)

        let baseEffect = self.base
            .dependency(
                \.restart,
                RestartEffect {
                    subject.send(restartAction)
                }
            )
            .reduce(into: &state, action: action)

        return .merge([restartEffect, baseEffect])
    }
}

extension Reducer {
    @inlinable
    @warn_unqualified_access
    public func restartAction(_ restartAction: Self.Action) -> RestartReducer<Self> {
        RestartReducer(base: self, restartAction: restartAction)
    }
}

The root reducer then just adds this line:

.restartAction(.restart)

Then then all the children can use the dependency like so:

@Dependency(\.restart) var restart

and simply restart the root reducer by returning a run effect that calls the restart:

return .run { send in
    await restart()
}
You must be logged in to vote

Replies: 1 comment

Comment options

I recently had the time to make this more generic and came up with this.

There are still several known issues:

  • The effect from the .listen reducer modifier is still running after a test ends.
    • Current workaround for us is to use await store.skipInFlightEffects(strict: true) at the end of tests
  • I am not quite sure on the performance implications as these effects are generate every time the reduce function is called.
  • Only one .listen in the chain of reducers gets the callback because of the override in the .listen function and it only goes from child to parent reducers.
  • Because of the previous point the name of the feature is quite misleading.

One can extend the BroadcastStorage like so to be able to use it:

extension BroadcastStorage {
    public var restart: BroadcastListenerEffect<Void> {
        get { self[#function] }
        set { self[#function] = newValue }
    }

    public var showDialog: BroadcastListenerEffect<String> {
        get { self[#function] }
        set { self[#function] = newValue }
    }
}

Then add the .listen modifier to the parent reducer like so:

// ...
public var body: some ReducerOf<Self> {
    Reduce { state, action in
        // ..
    }
    .listen(for: \.restart, then: .restart)
    .listen(for: \.showDialog, then: Action.showDialog)
}

and trigger them like this in a child:

// ...
@Dependency(\.broadcasts.showDialog) var showDialog
// ...
public var body: some ReducerOf<Self> {
    Reduce { state, action in
        // ...
        return .run { _ in
            await showDialog(with: "Hellp")
        }
        // ...
    }
}

Code:

import ComposableArchitecture

/// An effect that triggers a action on the reducer that listens for it.
///
/// Execute these in the effect returned from a reducer in order to trigger the
/// action in the listening parent reducer:
///
/// ```swift
/// @Reducer
/// struct ChildFeature {
///   struct State { /* ... */ }
///   enum Action {
///     case logoutButtonTapped
///     // ...
///   }
///   @Dependency(\.broadcasts.restart) var restart
///   var body: some Reducer<State, Action> {
///     Reduce { state, action in
///       switch action {
///       case .logoutButtonTapped:
///         return .run { _ in await restart() }
///       // ...
///       }
///     }
///   }
/// }
/// ```
///
/// This operation works by finding the nearest parent feature that uses the
/// `.listen(for: \.restart, then: ParentAction)` operator and sending the specified
/// parent action to that parent reducer.
///
/// ```swift
/// @Reducer
/// struct ParentFeature {
///   struct State { /* ... */ }
///   enum Action {
///     case restart
///     // ...
///   }
///   @Dependency(\.broadcasts.restart) var restart
///   var body: some Reducer<State, Action> {
///     Reduce { state, action in
///       switch action {
///       case .restart:
///       // ...
///       }
///     }
///     .listen(for: \.restart, then: Action.restart)
///   }
/// }
/// ```
///
/// > Warning: The `@Dependency(\.broadcasts.restart)` tool only works for features that are children of parents using
/// > the `listen` operator. If no parent feature is found calling the restart has no effect.
/// >
/// > If you are testing a child feature in isolation that makes use of `@Dependency(\.broadcasts.restart)`
/// > then you will need to override the dependency. You can even mutate
/// > some shared mutable state inside the `call` closure to confirm that it is indeed invoked.
public struct BroadcastListenerEffect<Value>: Sendable {
    var call: (@MainActor @Sendable (Value) -> Void)

    public init(_ call: @escaping @MainActor @Sendable (Value) -> Void) {
        self.call = call
    }

    @MainActor
    public func callAsFunction(with value: Value) async {
        self.call(value)
    }

    @MainActor
    public func callAsFunction() async where Value == Void {
        self.call(())
    }
}
import Combine
import ComposableArchitecture

public struct BroadcastListenerReducer<Base: Reducer, Value>: Reducer {
    @usableFromInline
    let base: Base

    @usableFromInline
    let keyPath: WritableKeyPath<BroadcastStorage, BroadcastListenerEffect<Value>>

    @usableFromInline
    let broadcastAction: (Value) -> Base.Action

    @usableFromInline
    init(
        base: Base,
        keyPath: WritableKeyPath<BroadcastStorage, BroadcastListenerEffect<Value>>,
        broadcastAction: @escaping (Value) -> Base.Action
    ) {
        self.base = base
        self.keyPath = keyPath
        self.broadcastAction = broadcastAction
    }

    @inlinable
    public func reduce(
        into state: inout Base.State, action: Base.Action
    ) -> Effect<Base.Action> {
        let subject = PassthroughSubject<Base.Action, Never>()

        let broadcastEffect = Effect<Base.Action>.publisher {
            subject
        }
        .cancellable(id: keyPath, cancelInFlight: true)

        let baseEffect = self.base
            .dependency(
                (\DependencyValues.broadcasts).appending(path: keyPath),
                BroadcastListenerEffect { value in
                    subject.send(broadcastAction(value))
                }
            )
            .reduce(into: &state, action: action)

        return .merge([broadcastEffect, baseEffect])
    }
}

extension Reducer {
    @inlinable
    @warn_unqualified_access
    public func listen<Value>(
        for keyPath: WritableKeyPath<BroadcastStorage, BroadcastListenerEffect<Value>>,
        then action: @escaping (Value) -> Self.Action
    ) -> BroadcastListenerReducer<Self, Value> {
        BroadcastListenerReducer(base: self, keyPath: keyPath, broadcastAction: action)
    }

    @inlinable
    @warn_unqualified_access
    public func listen(
        for keyPath: WritableKeyPath<BroadcastStorage, BroadcastListenerEffect<Void>>,
        then action: Self.Action
    ) -> BroadcastListenerReducer<Self, Void> {
        BroadcastListenerReducer(base: self, keyPath: keyPath, broadcastAction: { _ in action })
    }
}
import ComposableArchitecture

public struct BroadcastStorage {
    @usableFromInline
    var storage: [String: any Sendable]

    init(storage: [String: any Sendable] = [:]) {
        self.storage = storage
    }

    public subscript<Value>(
        key: String
    ) -> BroadcastListenerEffect<Value> {
        get {
            guard let value = storage[key] else {
                return BroadcastListenerEffect<Value> { _ in }
            }

            return value as! BroadcastListenerEffect<Value>
        }
        set {
            storage[key] = newValue
        }
    }
}

extension BroadcastStorage: DependencyKey {
    public static let liveValue = BroadcastStorage()
    public static let testValue = BroadcastStorage()
}

extension DependencyValues {
    public var broadcasts: BroadcastStorage {
        get { self[BroadcastStorage.self] }
        set { self[BroadcastStorage.self] = newValue }
    }
}
You must be logged in to vote
0 replies
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Category
💡
Ideas
Labels
None yet
1 participant
Morty Proxy This is a proxified and sanitized view of the page, visit original site.