How can The Composable Architecture really become composable?

This thread is a continuation of IfLetStore and Effect cancellation on view disappear, but covers more aspects of the issues discussed.

My understanding is that TCA is underdelivering on its promise to be composable. TCA is great for creating tree of reducers, state trees, scoping reducers etc, but problems arise if you want to reuse views and reducers. I think reuse is an important aspect of composition.

How does TCA underdeliver on "reuse"?

In our app we have some basic views we reuse a lot. They mainly look like this

let avatarReducer = Reducer<AvatarState, AvatarAction, Void> { state, action, env in
    
    struct Cancellation: Hashable {}

    switch action {

    case .onAppear:
        return env.load(userId: state.userId)
            .map { AvatarAction.loaded($0) }
            .replaceError(with: AvatarAction.failed(.network))
            .eraseToEffect()
            .cancellable(id: Cancellation())
        
    case .loaded(let name):
        state.image = UIImage(systemName: name)
        return .none
        
    case .failed(let error):
        state.error = error
        return .none
            
    case .onDisappear:
        return .cancel(id: Cancellation())
    }
}

We have many of these AvatarViews on the screen at the same time. Our AvatarViews may appear anywhere in the view tree and reducer tree. We do a lot of pullback with optional reducers (like when a user is navigation to a detail view).

As you can see the AvatarView is itself responsible for loading its data – in the onAppear-case we are starting an async effect. We don't know how long time it may take. It is very important that the AvatarView is cancelling its ongoing effect when it is removed from the view tree (or the reducer tree).

Since we have many AvatarViews on the screen at the same time we don't want them to be in conflict of each other. To avoid one avatarReducer to unintentionally cancel other avatarReducers we pass a cancellation id from the outside and in like this:

+ struct AvatarEnvironment {
+     var cancellationId: AnyHashable
+ }

- let avatarReducer = Reducer<AvatarState, AvatarAction, Void> { state, action, env in
+ let avatarReducer = Reducer<AvatarState, AvatarAction, AvatarEnvironment> { state, action, env in
    
-   struct Cancellation: Hashable {}
    
    switch action {

    case .onAppear:
        return env.load(userId: state.userId)
            .map { AvatarAction.loaded($0) }
            .replaceError(with: AvatarAction.failed(.network))
            .eraseToEffect()
-           .cancellable(id: Cancellation())
+           .cancellable(id: env.cancellationId)
        
    case .loaded(let name):
        state.image = UIImage(systemName: name)
        return .none
        
    case .failed(let error):
        state.error = error
        return .none
            
    case .onDisappear:
-       return .cancel(id: Cancellation())
+       return .cancel(id: env.cancellationId)
    }
}
let contactReducer = Reducer<ContactState, ContactAction, Void>.combine(
    avatarReducer.pullback(
        state: \.me,
        action: /ContactAction.me,
        environment: { env in
            struct Cancellation: Hashable {}
            return AvatarEnvironment(cancellationId: Cancellation())
        }
    ),
    avatarReducer.pullback(
        state: \.peer,
        action: /ContactAction.peer,
        environment: { env in
            struct Cancellation: Hashable {}
            return AvatarEnvironment(cancellationId: Cancellation())
        }
    ),
    Reducer { state, action, env in
        ...
    }
}

Now these AvatarViews may be used all over the place by FriendsView, ProfileView and ContactView. There might be multiple instances of ContactView at the same time so ContactView also needs to get a cancellation id from the outside.


+ struct ContactEnvironment {
+     var cancellationId: AnyHashable
+ }

- let contactReducer = Reducer<ContactState, ContactAction, Void>.combine(
+ let contactReducer = Reducer<ContactState, ContactAction, ContactEnvironment>.combine(
    avatarReducer.optional().pullback(
        state: \.me,
        action: /ContactAction.me,
        environment: { env in
            struct Cancellation: Hashable {}
-           return AvatarEnvironment(cancellationId: Cancellation())
+           return AvatarEnvironment(cancellationId: [cancellationId, Cancellation()])
        }
    ),
    avatarReducer.optional().pullback(
        state: \.peer,
        action: /ContactAction.peer,
        environment: { env in
            struct Cancellation: Hashable {}
-           return AvatarEnvironment(cancellationId: Cancellation())
+           return AvatarEnvironment(cancellationId: [cancellationId, Cancellation()])
        }
    ),
    Reducer { state, action, env in
        ...
    }
}

PS: Combining cancellation ids with array's is a nice way of making sure they are unique and scoped properly.

Now we are closing in on the heart of the problem. The ContactView is optional

struct DetailState: Equatable {
    var contactState: ContactState?
}

Whenever an AvatarView is removed the AppAction.detail(.me(.onDisappear)) case and AppAction.detail(.peer(.onDisappear)) should be reduced, but it is too late, the contactState has been nilled out and without state we can not reduce. This leads to a crash :boom:

In this thread IfLetStore and Effect cancellation on view disappear we look at solutions for this specific issue, but as of today no solution has come up that is sufficient in my eyes.

How can we make TCA better support reuse?

Sorry for the long introduction, but this issue should be looked at holistically. I think these points should be considered when discussing solutions

  • TCA should always assume views and reducers are reused
  • TCA should always assume reducers needs to stop ongoing effects
  • Every reducer/view/store should be able to cancel its ongoing effects without affecting other "instances"
  • There should be a minimum of pitfalls and gotchas to learn

Gotcha 1 – Forgetting to cancel one or more underlying effects

This is the most scary one as it will cause your app to crash if you don't pay close attention in code review or when writing code.

Example: ContactView has 2 AvatarViews. The developer remembers to cancel just one of them.

This will be difficult to find, discover and debug as it is a timing issue. It will happen some times for some users in production because maybe you have a network call that is completed in most cases before cancellation is happening?

Gotcha 2 – Forgetting to create a unique cancellation

I have outlined one way for developers to create unique cancellation ids by passing them in from outside, but if developers forget to do this they will suddenly find that reducers and views are unintentionally affecting each other when it comes to cancellation.

Example: Removing AvatarView 1 cancels ongoing effects in AvatarView 2.

Gotcha 3 – Cancellation id's might get mistyped because of retyping

If developers need to retype construction of cancellation ids there's a big chance they mistype or get out of sync.

Gotcha 4 – There's more to cancel down the chain

A view might use several other views and reducers which also needs to cancel their long going effects. If a reducer/view is not able to cancel their own effects and it must be done from the outside a long chain of cancellation ids must be constructed and cancelled. This can

Example: Cancelling all underlying effects here might be error prone

DetailView 
  -cancel-> ContactView 
     -cancel-> AvatarView instance 1
        -cancel-> AvatarFavoriteColorView
     -cancel-> AvatarView instance 2
        -cancel-> AvatarFavoriteColorView

Suggestions

I don't think I have the perfect answer here. This is quite complicated and it is quite complicated to find solutions within the current structure of the framework (to me at least).

Alt 1: Doing tear down via ViewStore

If the Store knew which long living effects it produced then maybe we could cancel all of those?

Example

struct DetailView: View {
    let store: Store<DetailState, DetailAction>

    var body: some View {
        WithViewStore(store) { viewStore in
            VStack {
                AvatarView(
                    store: self.store.scope(
                        state: \.me,
                        action: { .me($0) },
                        cancellationId: [store.cancellationId, "me"]
                    )
                )
                Text("Me").font(.title)
            }.onAppear {
                viewStore.send(.onAppear)
            }.onDisappear {
-               viewStore.send(.onDisappear)
+               viewStore.cancelAll() 
            }
        }
    }
}

I have a proof of concept of this here

and a draft here

Cons: Separation of cancellation id on store and environment+reducers is probably weird looking?

Pros: Not a lot required of the developers. Easy to tear down a whole host of cancellation ids at once.

Alt 2: Remove the global cancellation array

Today, cancellation is happening via a global singleton!

var cancellationCancellables: [AnyHashable: Set<AnyCancellable>] = [:]

I don't like it because of all the gotchas it introduces. I would rather have that singleton usage explicit like this

- Effect.cancellable(id: env.cancellationId)
+ Effect.cancellable(id: env.cancellationId, bag: CancellationBag.global)

This could open some doors. When we create cancellation bags that could be passed down the chain we can cancel a whole host of id's at once like this

Effect.cancel(bag: env.cancellationBag)

Alt 3: Environments can return all cancellable ids

If all environments can return all sub environments then we can make them all conform to returning cancellation ids in the whole tree

Example:

struct DetailEnvironment {
    var cancellationId: AnyHashable
    
    var cancellationIds: [AnyHashable] {
        return [cancellationId] + meEnv.cancellationIds + peerEnv.cancellationIds
    }
    
    var meEnv: AvatarEnvironment {
        struct Cancellation: Hashable {}
        return AvatarEnvironment(cancellationId: [cancellationId, Cancellation()])
    }
    
    var peerEnv: AvatarEnvironment {
        struct Cancellation: Hashable {}
        return AvatarEnvironment(cancellationId: [cancellationId, Cancellation()])
    }
}

struct AvatarEnvironment {
    var cancellationId: AnyHashable
    
    var cancellationIds: [AnyHashable] {
        return [cancellationId]
    }
}

Then when doing pullbacks we could avoid repeating ourselves and do

    avatarReducer.pullback(
        state: \.peer,
        action: /DetailAction.peer,
        environment: { env in
-           struct Cancellation: Hashable {}
-           return AvatarEnvironment(cancellationId: Cancellation())
+           return env.peerEnv
        }
    ),

And finally when we want to nil out a optional state-tree we can clear all cancellation id's

case .closedDetailView:
   return Effect.concatenate(env.cancellationIds.map { 
       Effect.cancel(id: $0) 
   })

Cons: More code? Easy to forget some cancellation id's.

Alt 4: Optional stores cancels automatically

I believe some solution like

- myReducer.optional()
+ myReducer.optional(cancellationBag: env.cancellationBag)

would be beneficial. All pending effects could be cancelled at the moment the state is nilled out (or next time it tries to reduce an optional state for that cancellation-id/bag.

Alt 5: Life cycle functions

In this thread IfLetStore and Effect cancellation on view disappear a lifecycle concept is discussed. It works for optional reducers, but not for optional reducers with non optional children. Maybe this concept could be made to support that? I don't know.

Some other solution ??

I don't know if this is relevant, maybe there's some work going on here? https://github.com/pointfreeco/swift-composable-architecture/compare/main...apple-cancellation

Way forward

I urge @stephencelis and @mbrandonw to have a look at this. They probably see the whole picture and know ins and outs of TCA. This is an important step in making TCA more mature and composable.

I have spent quite a lot of time now on this issue and I hope we together can save more sunk time on this.

16 Likes

I have not heard anything from anyone. I think the best solution going forward is alternative 1

SomeView().onDisappear {
     viewStore.cancelAll() 
}

That is something the authors of TCA would have to approve. I still have hope :slight_smile:

Workaround: CancellationBag

In the meantime while waiting for TCA authors I have created a workaround I am happy with – which I also think wold be a good pull request for TCA. You'll find the PR here!

A CancellationBag is a class that has the ability to cancel a collection of cancellation id's

public final class CancellationBag {
   ...
   public static func bag(id: AnyHashable) -> CancellationBag 
   public func cancelAll()   // cancel all cancellables
   public func cancel(id: AnyHashable) // cancel one cancellable
}

Your environment can by default create the bag like this

struct AvatarEnvironment {
    struct BagID: Hashable { }
    var bag = CancellationBag.bag(id: BagID())
}

Our aim is to reuse and have multiple instances of AvatarEnvironment at the same time so the owner of the AvatarView may override the environment

struct DetailEnvironment {
    struct BagID: Hashable { }
    var bag = CancellationBag.bag(id: BagID())
    
    var me: AvatarEnvironment {
        struct BagID: Hashable { }
        return AvatarEnvironment(bag: CancellationBag.bag(id: BagID()).asChild(of: bag))
    }
    
    var peer: AvatarEnvironment {
        struct BagID: Hashable { }
        return AvatarEnvironment(bag: .bag(id: BagID()))
    }
}

This seems like a mouthfull so it can be simplified into

struct DetailEnvironment {
    var bag = CancellationBag.autoId()
    var me: AvatarEnvironment { .init(bag: .autoId(childOf: bag)) }
    var peer: AvatarEnvironment { .init(bag: .autoId(childOf: bag)) }
}

by using a simple extension

extension CancellationBag {
  public static func autoId(childOf parent: CancellationBag? = nil, file: StaticString = #file, line: UInt = #line, column: UInt = #column) -> CancellationBag
}

This extension creates a unique AnyHashable based on file, line number and column number of caller.

Further, now that we have a bag on our environment, we can now start using this CancellationBag.

First place is in the side effects we initiate.

    case .onAppear:
        struct Cancellation: Hashable {}
        return Publishers.Timer(
            every: 1,
            tolerance: .zero,
            scheduler: DispatchQueue.main,
            options: nil
        )
        .autoconnect()
        .catchToEffect()
-       .cancellable(id: .Cancellation())
+       .cancellable(id: .Cancellation(), bag: env.bag)
        .map { _ in DetailAction.timerTicked }

Secondly, to the optional reducer we can now specify a CancellationBag

    detailReducer.optional(cancellationBag: { $0.bag }).pullback(
        state: \.detail,
        action: /AppAction.detail,
        environment: { $0.detail }
    ),

The optional reducer will then automatically cancel all ongoing side effects if the state is nil

extension Reducer {
    public func optional(cancellationBag: @escaping (Environment) -> CancellationBag) -> Reducer<
        State?, Action, Environment
    > {
        .init { state, action, environment in
            guard state != nil else {
                cancellationBag(environment).cancelAll()
                return .none
            }
            return self.run(&state!, action, environment)
        }
    }
}

So basically when you try to do this...

    MyView().onDisappear {
        viewStore.send(.onDisappear)
    }

...then it will just work™! Because if the reducer is in an optional and nilled out state tree the cancellation is happening for all cancellation bags down the chain whenever an action is sent. And further if the reducers state is not nilled out it will be able to cancel by itself by doing

    case .onDisappear:
        return .cancelAll(bag: env.bag)
    }

:sunny:

I think this solution covers these considerations:
:white_check_mark: TCA should always assume views and reducers are reused
:white_check_mark: TCA should always assume reducers needs to stop ongoing effects
:white_check_mark: Every reducer/view/store should be able to cancel its ongoing effects without affecting other "instances"
:white_check_mark: There should be a minimum of pitfalls and gotchas to learn

Summary about CancellationBag

In essence all you need to know is that you can specify a cancellation bag to the optional reducer

reducer.optional(cancellationBag: { $0.bag })

you can scope your cancellable effects

effect.cancellable(id: Cancellation(), bag: env.bag)

and you can can create an overridable cancellation bag for your environment like this

struct AvatarEnvironment {
    var bag = CancellationBag.autoId()
}

A working demo of the solution can be found here:

Let me know your thoughts :blush:

Also, the workaround can be simply dragged and dropped into your project, you only need this file.

Really nice approach when combined with the optional reducer !
How many times did I had to implement some complex cancellation when swiping back

1 Like

How many times did I had to implement some complex cancellation when swiping back

Yeah, right? :joy:It quickly gets messy.

Terms of Service

Privacy Policy

Cookie Policy