This thread is a continuation of IfLetStore and Effect cancellation on view disappear - #12 by hfossli, 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
In this thread IfLetStore and Effect cancellation on view disappear - #11 by darrarski 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 - #2 by mbrandonw 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.