Create Effects from @Published property

I have an external dependency to a SPM package for a bluetooth heartrate sensor. The package contains a PolarService class. This has a deviceConnectionState @Published property that represents the current connection state: either .disconnected, .connecting or .connected. The package makes sure the property's value is updated according to the device connection status

My question: if I understand the concept of TCA correctly, I have to somehow create an effect out of this value change to make that trigger an action and e.g. update my UI accordingly. But how do I hook into this?

To be honest, I bring no experience with Redux to the table, so maybe I'm missing some basic paradigm here. But I've spent several hours trying to find a way to do this now and am still clueless. Any advice?

Hey @mss-nms! Creating an Effect is indeed a way to do this, but this is not the most recent (nor preferred) approach since the "Concurrency" update from earlier this year.

The former approach was based on Combine and consisted in having a Publisher dependency, and to subscribe to these changes at .onAppear. Each time an event is published, a corresponding Effect<Action, Never> was produced. It worked well, but with many edge cases around cancellation. It still works, but it is sub-optimal.

The current approach is to convert your Publisher endpoint into an AsyncSequence of some sort. You then follow the same procedure, where you iterate in a .run effect over the async sequence of events and send an action each time a new value is received. But because it can exploit Swift concurrency, the API is more natural, and it furthermore can exploit SwiftUI's .task modifier to handle the observation lifecycle. You should probably look at the "LongLiving" case study in TCA's sample apps, where this procedure is performed over a stream of screenshot notifications.

Hey @tgrapperon! It's been a while but I finally managed to progress with this topic. With your pointers and some time, I finally got my sample project to the state I hoped it to be. For documentation for me and others with the same question, I'll outline my solution here.

The "LongLiving" case study proved to be a helpful example. I nevertheless had some modifications to make – and to learn about the modern concurrency mechanisms, having not had the opportunity to use them before. I created a ConnectionController wrapper class that instantiates the PolarService as a private property. Calls to connect() and disconnect() are then simply forwarded to that.

The isDeviceConnected property however got wrapped like this

var connectionChanged: AsyncStream<Bool> {
    AsyncStream { continuation in
        cancellable = self.service.$isDeviceConnected.sink { connected in
            continuation.yield(connected)
        }
    }
}

I have to admit I don't understand why the @Sendable () async -> part of the signature in the "LongLiving" example is necessary – for me, that just makes the return type incompatible.

Looking at the Reducer counterpart of this, I added my ConnectionController as a @Dependency in the feature struct and introduced a case connectionStatusChanged(Bool) and a connectionTask action. Here they are for reference:

case .connectionTask:
    return .run { send in
        for await isConnected in self.polarService.connectionChanged {
            await send(.connectionStatusChanged(isConnected))
        }
    }
    
case let .connectionStatusChanged(connected):
    state.connected = connected
    return .none

Here, we extract any value changes out of the AsyncStream using a for-in loop over it that is simply sending newly received values to another action handler. That construct itself is wrapped in a .run Effect that gets kicked of by attaching this modifier to a view inside the WithViewStore helper:

.task {
    viewStore.send(.connectionTask)
}

Please note that I left of the .finish() modifier found in the "LongLiving" example. The reason is that I want to keep listening to value changes even when a new view gets pushed over my current view. In fact, that's the whole idea here:

NavigationLink(isActive: .constant(viewStore.connected)) {
    DataListView(store: self.store.scope(state: \.dataList,
                                         action: Start.Action.dataList))
} label: {
    EmptyView()
}

When we are connected, I want to show a different view, but automatically pop back when disconnected; be it by the device being turned off or by the user tapping "Disconnect" manually. I found that events would no longer be received once the DataListView was shown, but that removing finish() changed that. Wouldn't have guessed that by the docs, still not sure if it's intended to be used like this.

Anyway, that outlines my solution. Feel free to let me know if you consider this approach "proper" or let me know what you would change if it's too hacky.

UIApplication is annotated @MainActor. This forces UIApplication.userDidTakeScreenshotNotification to be accessed from the MainActor, and this propagates to NotificationCenter.default.notifications(named: …), and then to AsyncStream(NoficationCenter.default…). So it means that accessing this AsyncStream will happen on the MainActor. Since the liveValue "function" is not isolated, you can't touch UIApplication.userDidTakeScreenshotNotification without awaiting or being on MainActor yourself. Because this dependency can be accessed from background tasks, the natural choice is to await instead of making it MainActor. The @Sendable requirement is there because @Dependency forces you to produce Sendable values, so you need to annotate the exposed dependency as such. I hope this makes sense.

There are probably other ways to implement this and synchronously produce an AsyncStream. I think I already created an AsyncStream wrapper that made an async AsyncStream a sync AsyncStream where the awaiting for the base AsyncStream was coalesced with the first value emission. I should maybe pitch it somewhere.

You should still consider a way to cancel the iteration from somewhere. Of course, you may want it to continue if you go beyond this view in your app, but if you model your navigation with optionals or enums, and if you pop this base view at some point (that is if you go back to a parent), the iteration will continue, and you will get purple warnings because the reducer will receive actions emitted from a feature with a nil state, which is considered to be a programming error. And if you go back to this view again, you will start to receive duplicated effects because the zombie iteration(s) will emit again.
The upcoming navigation update should take care of this kind of situation and will likely automatically cancel effects once views are popped.

Also beware of EmptyView, as this is not a "standard" View. If it works in your case, it's fine, but there are many situations where it isn't, and you can pull your hair trying to understand what's going on. The situation is simpler on iOS 16, as you can use .navigationDestination(isPresented: Binding<Bool>) { … }.

I hope this helps!