Async/Await -> Effect

I was working in TCA and wondered where async/await could fit in...my first thought was to add a convenience init to the Reducer that is async like this:

extension Reducer {
    public init(
        updateState: @escaping (inout State, Action) -> Void,
        effects: @escaping (Action, Environment) async -> Action
    ) {
        self.init { state, action, environment in
            updateState(&state, action)
            return Future { promise in
                async {
                    let action = await effects(action, environment)
                    promise(.success(action))
                }
            }.eraseToEffect()
        }
    }
}

But this has the unfortunate consequence of needing to use two separate closures, one for syncronous state changes and the second for asynchronous effects. Switching twice seems a bit cumbersome. Next I tried creating an easy way to form effects from async code:

public func effect<Output>(
    scheduler: AnySchedulerOf<DispatchQueue>,
    action: @escaping () async -> Output
) -> Effect<Output, Never> {
    Future<Output, Never> { promise in
        async {
            promise(.success(await action()))
        }
    }.receive(on: scheduler, options: nil).eraseToEffect()
}

Another function could be created to use AsyncSequence when many results are expected. (I defined this as a global function because there seemed to be a bug inferring async { ... } inside an extension on Effect. It was confusing it for a static func on a publisher?). In practice this looks like:

        case .fetchAccounts:
            state.loading = true
        
            return effect(scheduler: environment.mainQueue) { [state] in
                do {
                    let accounts = try await environment.accountsService.fetchAccounts(state.user)
                    return .receivedAccounts(accounts)
                } catch {
                    return .failedToReceiveAccounts(.requestFailed(error.localizedDescription))
                }
            }

These are pretty rough ideas but fun to work through. Has anyone else though through how async/await could fit into TCA?

State, in SwiftUI at least, is @MainActor-isolated (actually @MainActor(unsafe) for compatibility). So, I suggest marking state-handling code @MainActor-isolated. As to how you could incorporate async/await, I don’t think I have something better to offer compared to your first example.

That makes sense! I currently am not using the Reducer initializer since managing two separate closures that switch over the same enum feels overly cumbersome. But it would be nice to build that in somehow.

I have also added this function. I am definitely not sure about the name of it....but basically it takes a throwing async function, catches it to a result type, and then embeds that result into the provided Action case.

public func catchEffect<Action, Output>(
    scheduler: AnySchedulerOf<DispatchQueue>,
    assignedTo someCase: @escaping (Result<Output, NSError>) -> Action,
    action: @escaping () async throws -> Output
) -> Effect<Action, Never> {
    Future<Output, NSError> { promise in
        async {
            promise(.success(try await action()))
        }
    }.receive(on: scheduler, options: nil).catchToEffect().map(someCase).eraseToEffect()
}

A call site example:

public enum AccountAction: Equatable {
    case receiveShareIdentifiers(Result<ShareAccountsClient.ShareIdentifiers, NSError>)
    ...
}

public static let live = Self { state, action, environment in
    switch action {
    case .requestShareIdentifiers:
        return catchEffect(
            scheduler: environment.mainQueue,
            assignedTo: AccountAction.receiveShareIdentifiers
        ) {
            try await environment.shareAccountsClient.shareAccountContainer()
        }
        ...
}

```

Effect is akin to Swift Concurrency’s Task. So, I’d recommend making effect(scheduler:action:) an Effect initializer following Task's precedent:

extension Effect where Failure == Never {
  public init(
    _ action: @Sendable @escaping () async -> Output
  ) { ... }
}

Likewise, catchEffect(scheduler:assignedTo:action:) could become a static method on Effect like Task.detached:

extension Effect where Failure == Never {
  public static func catching<ActionOutput>(
    mapResult: @escaping (Result<ActionOutput, Error>) -> Output,
    _ action: @Sendable @escaping () async throws -> ActionOutput
  ) { ... }
}

Note that I didn't include a scheduler as a parameter, as Swift should automatically handle creating detached tasks. Having said that, I'm not sure how the implementation should look like, without changing the internals of Effect to support Swift Concurrency out of the box.

Nice suggestions! I originally did not define as an initializer because of an odd bug where async { ... } (soon to be task) is mistaken for an ambiguous static function on Effect. I was able to get around this temporarily by doing the following:

private func fulfill<Output>(
    _ promise: @escaping ((Result<Output, Never>) -> Void),
    with action: @escaping () async -> Output
) {
    async { [promise] in
        promise(.success(await action()))
    }
}

private func fulfill<Output>(
    _ promise: @escaping ((Result<Output, NSError>) -> Void),
    with action: @escaping () async throws -> Output
) {
    async {
        do {
            promise(.success(try await action()))
        } catch {
            promise(.failure(error as NSError))
        }
    }
}

extension Effect where Failure == Never {
    public init(
        receiveOn scheduler: AnySchedulerOf<DispatchQueue>,
        action: @escaping () async -> Output
    ) {
        self = Future<Output, Never> { promise in
            fulfill(promise, with: action)
        }.receive(on: scheduler, options: nil).eraseToEffect()
    }
    
    public static func `catch`<Action, Output>(
        receiveOn scheduler: AnySchedulerOf<DispatchQueue>,
        embedIn someCase: @escaping (Result<Output, NSError>) -> Action,
        action: @escaping () async throws -> Output
    ) -> Effect<Action, Never> {
        Future<Output, NSError> { promise in
            fulfill(promise, with: action)
        }.receive(on: scheduler, options: nil).catchToEffect().map(someCase).eraseToEffect()
    }
}
1 Like

FWIW we also just removed some old deprecated code, including that async function, so you shouldn't get that error anymore. Though it won't matter much once Task is properly in place :slightly_smiling_face:

1 Like