Side Effects with Combine

Side Effects with Combine

Leveraging Combine’s publisher events for logging and event tracking

Subscribe to my newsletter and never miss my upcoming articles

One of the benefits of adopting Combine's publisher approach to asynchronous programing is that every operation is a stream or pipeline that we can subscribe and react to via powerful operators.

This works really well for situations where would like to execute code outside the scope of a publisher as certain events occur. Such executions are often described as side effects

What are side effects?

For the context of this article and within the realm of Combine, we can define side effects as invocations that do not transform the output of a publisher which are triggered when certain events occur during a publisher's lifecycle.

Common use cases for side effects in reactive programming include but are not limited to:

  • Debugging
  • Error handling
  • Event tracking
  • Persisting data

So how would we go about implementing side effects in Combine?

Handling events

Combine provides a useful handleEvents() operator that allows us to provide closures that can be performed when certain publisher events occur. This makes it possible, for example, to log relevant information in the event of a non-fatal error:

todoRepository.addTodo(title: title)
    .handleEvents(receiveCompletion: { [logger] completion in
        switch completion {
        case .failure(let error):
            logger.error(error)
            debugPrint("an error occurred: \(error)")
        case .finished:
            debugPrint("addTodo publisher completed")
        }
    })

In addition to the receiveCompletion parameter, the operator provides other events that we can hook into that can serve a variety of use cases:

receiveSubscription:

Executes when the publisher receives the subscription from the upstream publisher. A possible use case for this would be to launch a background process/experience whenever a consumer subscribes:

videoCallProvider.acceptCall()
    .handleEvents(receiveSubscription: { [cameraManager] _ in
        cameraManager.startCapture()
    })

receiveOutput:

Executes when the publisher receives a value from the upstream publisher. In the pattern we put together in my previous article Managing View State Combine, we could use this to keep track of Inputs as users interact with our views:

inputSubject
    .handleEvents(receiveOutput: { [eventTracker] input in
        switch input {
        case .addTodo:
            eventTracker.track(.todoAdded)
        case .todoRemoved:
            eventTracker.track(.todoRemoved)
        }
    })

receiveCancel:

Executes when the downstream receiver cancels publishing. A good use case for this would be disposing of no longer needed processes that live outside of a publisher's lifecycle:

videoCallProvider.acceptCall()
    .handleEvents(receiveCancel: { [cameraManager] in
        cameraManager.endCapture()
    })

receiveRequest:

Executes when the publisher receives a request for more elements. Had a hard time thinking of a good use case for this one, but could come in handy during situations where we would like know the amount of outputs being requested by a subscriber.

Note: closures passed to these parameters are escaped, and as a result, subject to the dangers of strong reference cycles or wrong thread executions. Make sure to avoid strong references by using capture lists and insuring the closure is performed on the appropriate thread via the receive(on:) operator.

A tip when handling events 💡:

You might have noticed when typing handleEvents, Xcode will autofill all possible parameters of the method. This can be annoying to deal with, especially if we find ourselves handling specific events frequently. Lets try and fix that with some convenient extensions:

extension Publisher {
    func handleOutput(_ receiveOutput: @escaping ((Self.Output) -> Void)) -> Publishers.HandleEvents<Self> {
        handleEvents(receiveOutput: receiveOutput)
    }

    func handleError(_ receiveError: @escaping ((Self.Failure) -> Void)) -> Publishers.HandleEvents<Self> {
        handleEvents(receiveCompletion: { completion in
            switch completion {
            case .failure(let error):
                receiveError(error)
            case .finished:
                ()
            }
        })
    }
}

With the extensions above, not only will interacting with the operator become more enjoyable, but the end result ends ups looking more readable:

todoRepository.addTodo(title: title)
    .handleOutput({ [eventTracker] _ in
        eventTracker.track(.todoAdded)
    })
    .handleError({ [logger] error in
        logger.error(error)
        debugPrint("an error occurred: \(error)")
    })

Conclusion

In situations where we would need to execute some code along side other asynchronous code, the Combine framework can serve as a good candidate given its elegant approach to performing side effects.

If you liked this article, follow me on Twitter for more updates and content about Swift development, architectural patterns and clean code.

 
Share this