How to send back an action from a view store publisher?

I have a button that sends a rewind command... which sets a shouldRewind flag in my state.

My view controller subscribes to a view store.publisher for shouldRewind and rewind something if true.

I tried sending back an action after rewinding to reset the shouldRewind state to false but I get hit by this dreadful assertion error:

      * The store was sent an action recursively. 

I don't seem to be sending that action recursively.

      * The store has been sent actions from multiple threads. 

All actions are sent from the main thread.

I am really confused.

I have dropped the second action and I reset the flag right away with an effect.

        case .shouldRewind(let flag):
            state.shouldRewind = flag

            return { environment in
                guard flag else { return .none }

                return Effect(value: .shouldRewind(false))
                    .eraseToEffect()
            }
        }

I am still curious as to why my naive approach didn't work.

You almost certainly are sending an action recursively. The store's send(_:) method calls out to the reducer. As the reducer returns, it updates the store's state, which publishes an output. You're subscribed to that publisher, and in your subscriber you're calling back into send(_:).

Put a breakpoint on the assertionFailed in send(_:) and try again. When the breakpoint is hit, check the stack trace. If you see two stack frames calling send(_:), then you are re-entering send(_:) while it is updating state.

Thanks, what's the proper way to do it?

It looks like your rewrite is a correct solution to fixing the recursive send problem.

However, I guess you're toggling shouldRewind to trigger a side effect in your view controller. You should probably be performing that side effect via an Effect returned by your reducer.

I'm guessing you have a setup like this:

  • You have a video (or audio) player managed by the view controller.
  • A tap on the rewind button should tell the video player to rewind.
  • Neither the video player object nor the current playback time are part of the store's state.

Here's a way to make the rewind happen in an Effect. Create a rewind action that includes the video player as an associated value:

enum VideoPlayerAction {
    case rewind(VideoPlayer)
    ...
}

With this action, your reducer can create an Effect that rewinds the video player:

switch action {
case .rewind(let player):
    return .fireAndForget { player.rewind() }
    // or maybe
    return .fireAndForget { environment.rewind(player) }
}

Thanks @mayoff

Protocols don't play really nice with Equatable.

If VideoPlayer is a protocol how can I keep AppAction as an Equatable?

public protocol Rewindable {
    func rewind(animated: Bool)
}

Adding protocol performance to the protocol we get the following error:

Protocol 'Rewindable' can only be used as a generic constraint because it has Self or associated type requirements

It doesn't seem like it is possible to make a property-less protocol conform to Equatable.

I don't even seem to be able to pass a closure instead of VideoPlayer...

Your second suggestion was to use the environment... how do I set environment.rewind if I can't have access to rewind until the app is running? Is there a mechanism to update the environment?

@stephencelis What is your opinion and guidance on this?

Should a view controller be sending back some code to execute in the reducer? It doesn't seem right...

I think the problem you're having here is because you're trying to control something from your reducer which it has no control over - your VideoPlayer is not part of your state or your environment. From how you've described it, it seems to be more equivalent to a view, so I would think of it that way and let changes to your state drive the VideoPlayer inside the view it's wrapped. When shouldRewind == true, trigger the rewind on your VideoPlayer inside the view then fire another action to notify that it has been rewound and update your state again.

Thanks and that's exactly what I was trying to do earlier and ended up calling back into send(_:) in my subscriber and I also have no way to tell when the view is rewound...

I guess I can just fire a onRewind action right away:

        case .rewind:
            if !state.shouldRewind {
                state.shouldRewind.toggle()
            }
            return { _ in .init(value: .onRewind) }
        case .onRewind:
            if state.shouldRewind {
                state.shouldRewind.toggle()
            }
            return { _ in .none }
        }

and in my view :

        viewStore.publisher.shouldRewind
            .sink { [weak self] ok in
                if ok { self?.rewind() }
            }.store(in: &cancellables)

That’s not quite what I was suggesting. You shouldn’t need to explicitly subscribe to the publisher in this way. Just do a simple conditional check of your state in your view to determine if you need to trigger the rewind, the view will re-render when the state changes.

@lukeredpath , I have no easy way to check if the player has done the rewind except in the subscription but inside the subscription I am not allowed to send an action.

Also I am using UIKit so I have to subscribe explicitly in viewDidLoad.

Sorry, I think I missed that you were using UIKit.

This might be a bit of a hacky workaround but what happens if you send the onRewind action to the store inside a dispatch_async block?

@lukeredpath, that worked, thanks :slight_smile:

@mycroftcanner could you show how would look your final code? I also have some similar scenario that I'm trying to send an action in inside the subscription ( I put my subscription in the environment object). And I have no idea how to solve it...

just wrap your send inside DispatchQueue.main.async { [weak self] in viewstore.send(...) }

I extracted work with stores and viewStores to a custom ComposableCore class and use such methods for sending actions

public func send(_ action: Action) {
    DispatchQueue.main.async { [weak self] in
        self?.sendSync(action)
    }
}
    
public func sendSync(_ action: Action) {
    viewStore.map { $0.send(action) }
}

But hopping to the main thread on combine may help too, I think.