Video #199: Async Composable Architecture: Effect Lifetimes
Episode: Video #199 Date: Aug 1, 2022 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep199-async-composable-architecture-effect-lifetimes

Description
We explore ways to tie the lifetime of an effect to the lifetime of a view, making it possible to automatically cancel and tear down work when a view goes away. This unexpectedly helps us write even stronger tests for our features.
Video
Cloudflare Stream video ID: 261e9a4e8e205b23400ab9d5e658051b Local file: video_199_async-composable-architecture-effect-lifetimes.mp4 *(download with --video 199)*
References
- Discussions
- isowords
- 0199-tca-concurrency-pt5
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
It’s a bit of a bummer that we need to insert this yield. We don’t even know if it’s enough to get things to always pass. Maybe if we run this enough times it will eventually fail, leading us to insert a few additional yields to push things along.
— 0:18
One thing that could potentially fix this is if Swift supported async deinit for objects, because then we could suspend for a bit when the test store is torn down to see if all effects finished, and if not then we could error. There has been some discussions of this in the evolution forums, but no movement on a final design yet.
— 0:38
But, even before we get that feature, there’s still something we can do to make this work deterministically without sprinkling yields into the test. It is possible to send an action to the store and get back a task that represents the lifecycle of the effect that was kicked off from that action. That would give you something concrete to await on so that you could suspend until the exact moment that the effect finishes. This would give us a 100% deterministic way to make our test pass.
— 1:06
It also turns out that by getting a handle on an effect’s lifecycle we can improve other parts of the library too. For example, SwiftUI has an interesting view modifier called .task that allows you to spin up some asynchronous work when the view appears, and that work is automatically cancelled when the view disappears.
— 1:23
Wouldn’t it be cool if we could send an action in that task modifier so that when the view disappears it cancels the inflight effect that was kicked off from the action? This makes it possible for a feature to seamlessly tear down its effects when the view disappears.
— 1:38
Let’s see how this is possible. The problem
— 1:42
First, before diving into the code to fix this, let’s see why a handle onto the lifecycle of an effect would help the test that was failing a moment ago. If you remember, we currently have a test that is failing because an effect does not complete by the time the test finishes.
— 1:59
Now technically the effect is in the process of completing, but due to the effect running in an asynchronous context, it needs just a little bit of time to fully finish up and tear down. To fix this we inserted a Task.yield at the end of the test, and that seemed to do the job, but we’re not sure that is going to 100% work all of the time.
— 2:15
So, what if the test store’s send method returned a value, which we will call “task” but is not literally going to be the Task type that the standard library ships: let task = store.send(.nthPrimeButtonTapped)
— 2:25
And then at the end of the test if we need to deterministically wait until the task finishes we can simply await it: await task.finish()
— 2:33
This would allow the effect to tell us when it is has fully finished, and we wouldn’t have to sprinkle in some Task.yield s and hope for the best.
— 2:40
So already that would be a big win. But by having a handle on the effect’s lifetime we can allow for even cooler things. To see this, let’s add a small piece of functionality to the demo. We are going to make it so that when you navigate to the screen, we preemptively go ahead and fetch the “nth prime” for whatever the current count is. As we’ve seen, this computation can be quite time intensive, and so if the user gets impatient and navigates away before it finishes, we would like to cancel the effect.
— 3:03
To implement this we will add an action that is sent when the view appears: enum EffectsBasicsAction: Equatable { … case onAppear … }
— 3:16
And then in the reducer we want to handle this action by kicking off that long-living effect that computes the prime number and reports its progress along the way. That was a pretty complex effect, and we already wrote it once before when the button is tapped, so it would be bummer to just copy-and-paste all that work.
— 3:44
Instead, we can breakout a little private, helper function that encapsulates that effect: private func nthPrime(number: Int) -> Effect<EffectsBasicsAction, Never> { .run { send in var primeCount = 0 var prime = 2 while primeCount < number { defer { prime += 1 } if isPrime(prime) { primeCount += 1 } else if prime.isMultiple(of: 1_000) { await send( .nthPrimeProgress( Double(primeCount) / Double(number) ), animation: .default ) await Task.yield() } } await send( .nthPrimeResponse(prime - 1), animation: .default ) } }
— 4:19
And then we can use it in both .onAppear and .nthPrimeButtonTapped : case .onAppear: return nthPrime(number: state.count) case .nthPrimeButtonTapped: return nthPrime(number: state.count)
— 4:33
And then finally we can send the action when the view appears: .onAppear { viewStore.send(.onAppear) }
— 4:48
So that was pretty straightforward, but there’s a problem.
— 4:56
Let’s quickly update the state so that its default count value is a large number, like 50,000: struct EffectsBasicsState: Equatable { var count = 50_000 … }
— 5:05
And now let’s run the application in the simulator. When we drill down to the basics case study, we will see that the screen is already in the process of loading up the 50,000th prime, and once the progress bar fills all the way up we get our answer.
— 5:20
So, that’s cool. But, if we go back to the home screen, and then drill into the case study and immediately drill back out, we can see in the logs that the effect is still running and still feeding its data back into the system. Even though we have already left the screen.
— 5:32
To fix this we should send an .onDisappear action when the view goes away. We can add the action to our enum, which keeps getting longer: enum EffectsBasicsAction: Equatable { case onDisappear … }
— 5:45
And we have to add a new cancellation identifier type: enum NthPrimeID {}
— 5:53
And then we have to tag the effect with that identifier and cancel it when the view disappears: case .onAppear: return nthPrime(number: state.count) .cancellable(id: NthPrimeID.self) case .onDisappear: return .cancel(id: NthPrimeID.self)
— 6:09
And then we just need to send onDisappear from the view: .onDisappear { viewStore.send(.onDisappear) }
— 6:20
This gets the job done, but it’s also a real pain and takes multiple steps to get right, including new actions that have to be handled in the view and reducer, and another cancellation identifier. So there’s a chance that we don’t do everything 100% correct.
— 6:55
What if instead you could use SwiftUI’s relatively new .task view modifier, which allows you to execute some asynchronous work when the view appears, and SwiftUI takes care of automatically cancelling the work when the view disappears. Then we could perhaps await sending the action: .task { await viewStore.send(.task) } // .onAppear { // viewStore.send(.onAppear) // } // .onDisappear { // viewStore.send(.onDisappear) // }
— 7:11
And hopefully everything would just work. We could get rid of the NthPrimeID type, and the .onAppear / .onDisappear actions, and the reducer could become simpler. View store tasks
— 7:21
This is all possible, and it just requires the Composable Architecture to be even more deeply integrated with Swift’s concurrency tools. The integration needs to first happen at the level of Store s and ViewStore s, since that is what is primarily responsible for sending and processing actions, and then once that is done we will be able to take advantage of it in the TestStore .
— 7:40
Let’s familiarize ourselves with how sending actions currently works under the hood, and then see what it takes to implement an asynchronous send method.
— 7:49
It all begins with the send method on ViewStore . In fact there are 4 of them: public func send(_ action: Action) { … } public func send( _ action: Action, animation: Animation? ) { … } public func send( _ action: Action, while predicate: @escaping (State) -> Bool ) async { … } public func send( _ action: Action, animation: Animation?, while predicate: @escaping (State) -> Bool ) async { … }
— 7:54
The first two are the simplest versions of send , one sending without specifying an animation and one by specifying an animation. Unfortunately we cannot squash these two methods into a single one with a default of nil for the animation because animating something with a nil animation means “don’t animate anything”, whereas simply not specifying an animation means use whatever the current animation context is.
— 8:13
The other two send methods are our first stab at introducing some of Swift concurrency tools to the Composable Architecture. They allow you to send an action into the system, and then suspend until the state no longer satisfies a predicate, for example if an isLoading boolean in the state flips from true to false . There’s also two flavors for this for if you want to specify an animation or not.
— 8:43
This version of send is handy when interfacing with SwiftUI’s .refreshable view modifier, which allows you to show a loading indicator while some effect is executing. This send will suspend while the predicate on state remains true, and so gives us a direct way to control when the loading indicator should go away.
— 9:07
However, needing to express this suspend as a predicate on state can be a little onerous on the user of this API. It forces us to introduce some state, such as a boolean, and manage that state by flipping it to true when an effect starts and flipping it back to false when the effect finishes. Sometimes is necessary to keep around that kind of state, like if some part of the view was dependent on whether or not we were loading, but often it is not necessary.
— 9:33
It would be far better if the suspension of the view store’s send method was tied to the lifecycle of the effect created from sending the action. Then display of the loading indicator would automatically be tied to the lifecycle of the effect without needing any extra state. And even better, we can make the cancellation of the asynchronous task of sending an action be tied to the effect created, which means we will get the behavior that if you come to a screen and leave right away we can cancel the effect automatically.
— 10:06
We might be tempted to simply introduce a version of send that is async : public func send(_ action: Action) async { … }
— 10:19
But there are a few problems with that. First, we would be introducing even more overloads to a space that is already quite overloaded. But worse, an async overload would force you to await the completion of an effect when send is called in an asynchronous context, or it would be up to you to remember to open up a synchronous context to call the synchronous version. { viewStore.send(action) }()
— 10:53
So instead, we will update the existing send method so that it returns a Swift Task that represents the effect’s lifetime: public func send( _ action: Action ) -> Task<Void, Never> { self._send(action) }
— 11:05
The task is Task<Void, Never> because it doesn’t produce anything of interest, and it cannot fail. Right now we are just returning a plain Swift Task , but soon we will actually wrap this up in our own type in order to improve the ergonomics.
— 11:13
In order for this to work we need the internal _send function to return a task too: private let _send: (Action) -> Task<Void, Never>
— 11:35
Which causes a compiler error when we initialize the view store’s _send by using the store’s send : self._send = { store.send($0) } Cannot convert value of type ‘()’ to closure result type ’Task<Void, Never>’ So, it seems that even the store’s send method needs to return a task: func send( _ action: Action, originatingFrom originatingAction: Action? = nil ) -> Task<Void, Never> { … }
— 11:43
And let’s just do the minimum work in here to return an empty task in order to get things compiling again: return Task {}
— 12:22
Everything is compiling now that we have threaded a task through every layer from store to view store, but of course it isn’t correct yet.
— 12:40
We need to somehow perform asynchronous work inside the task so that it suspends for as long as the effect is running in the store. To understand how to accomplish that let’s remind ourselves how the send method works.
— 12:51
The store’s send method is the real powerhouse of the application. It does the actual work of evolving the runtime to its next state, executing the effect, managing the effect’s lifecycle, and feeding the effect’s emissions back into the system. This method is decently long, but it’s also doing a lot of extra work to deal with edge cases that can crop up in complex applications. Luckily we don’t really have to know about all of those details.
— 13:16
The most important steps of this method are that we run the reducer in order to mutate state and get back an effect: let effect = self.reducer(¤tState, action)
— 13:24
And then we run the effect and feed any of its emissions back into the system: let effectCancellable = effect.sink( receiveCompletion: { [weak self] _ in self?.threadCheck(status: .effectCompletion(action)) didComplete = true self?.effectCancellables[uuid] = nil }, receiveValue: { [weak self] effectAction in self?.send(effectAction, originatingFrom: action) } )
— 13:31
These steps are wrapped in a loop because we do allow for reentrancy into the send method. That is, while the send method is in the middle of executing it is possible for another action to be sent into the system. In such a case we build up a buffer of actions that need to be processed all at once, which is why we have a loop.
— 13:53
That’s the basics of the send method, so how can we construct a task that suspends for as long as the effect runs?
— 14:00
Perhaps we can create a task that suspends essentially forever, or at least for a very, very long time, and then we cancel that task when the effect completes. So, right after we run the reducer, let’s create a task that just sleeps for a very long time, say, one billion seconds: let effect = self.reducer(¤tState, action) let task = Task { _ = try? await Task .sleep(nanoseconds: NSEC_PER_SEC * 1_000_000_000) }
— 14:22
Sleeping for a long time seems pretty hacky, and it is. There are better ways to create a task that never finishes, but there are subtleties to consider, and so for the purposes of this episode it’s totally fine to just sleep for a long time.
— 14:40
And then when the effect finishes we can cancel the task: let effectCancellable = effect.sink( receiveCompletion: { [weak self] _ in self?.threadCheck(status: .effectCompletion(action)) didComplete = true self?.effectCancellables[uuid] = nil task.cancel() }, … )
— 14:51
Now that task has the exact same lifetime as the effect.
— 15:01
Unfortunately we can’t simply return this task because it represents only one effect that might run. Remember that we are in a while loop in order to handle other actions that could have come in recursively. So, we can build up an array of tasks that represent the lifetimes of all the effects running: var tasks: [Task<Void, Never>] = []
— 15:31
And then append to it when we start a new task: let task = Task { _ = try? await Task .sleep(nanoseconds: NSEC_PER_SEC * 1_000_000_000) } tasks.append(task)
— 15:35
Now, at the end of the method when we need to return a single task we can construct a new task that simply awaits for all of the tasks in the array: return Task { [tasks] in for task in tasks { await task.value } }
— 15:39
This newly constructed task now lives for the exact same amount of time as the effect that is constructed from sending the action.
— 16:02
There are some warnings in our code about unused values since send now returns a task, and we aren’t doing with it, but we will worry about that later. With what we have done so far we can already take it for a spin to see that it does suspend for the lifetime of the effect.
— 16:33
Let’s start by simplifying our demo to get rid of the onAppear / onDisappear duo of actions, and just have a single task action: enum EffectsBasicsAction: Equatable { … case task … }
— 16:51
And then in the .task action we will invoke the nthPrime effect, but we no longer need to make it cancellable because hopefully that is just all taken care of automatically for us: // enum NthPrimeID.self … // case .onAppear: // return nthPrime(number: state.count) // .cancellable(id: NthPrimeID.self) // // case .onDisappear // return .cancel(id: NthPrimeID.self) case .task: return nthPrime(number: state.count)
— 17:07
And then we need to update the task modifier in the view, which has 2 warnings: .task { await viewStore.send(.task) } Result of call to ‘send’ is unused No ‘async’ operations occur within ‘await’ expression
— 17:21
First, let’s annotate the task-returning send method as discardable, since you can safely ignore the task it returns unless you want to await it: @discardableResult public func send(_ action: Action) async { … }
— 17:27
Second, we need to do the actual async work of awaiting the task’s value in the view modifier: .task { await viewStore.send(.task).value }
— 17:36
This compiles without warnings, but also isn’t the nicest looking thing in the world. Earlier we had theorized a finish() method for suspending: .task { await viewStore.send(.task).finish() } …which looked nice, so let’s support that really quickly. We will have view store’s send method return a custom type we control that wraps a task so that we can expose any kind of API we want. We will call this type ViewStoreTask : public struct ViewStoreTask { let rawValue: Task<Void, Never> public func finish() async { await self.rawValue.value } }
— 18:20
Which we will return from ViewStore.send : @discardableResult public func send(_ action: Action) -> ViewStoreTask { ViewStoreTask(rawValue: self._send(action)) }
— 18:41
To see that this really does suspend for the duration of the effect, let’s put a print statement after the await : .task { await viewStore.send(.task).finish() print("Effect finished!") }
— 18:52
Before running this in the simulator, let’s reduce the number of moving parts, let’s temporarily change the entry point to the application so that it’s just a simple navigation view with a single navigation link going to the effect basics view: @main struct CaseStudiesApp: App { var body: some Scene { WindowGroup { NavigationView { NavigationLink("Effect basics") { EffectsBasicsView( store: Store( initialState: EffectsBasicsState(), reducer: effectsBasicsReducer, environment: EffectsBasicsEnvironment( fact: .live, mainQueue: .main ) ) ) } } } } }
— 19:46
When we run the application and drill down to the effects screen we will see that “Effect finished!” does not print to the logs until the effect fully finishes, which takes a few seconds. This means we really are tying the lifetime of the effect to the lifetime of a task that we can await.
— 20:09
However, our implementation is not quite 100% correct yet. There are a few edge cases to think through. The first sign that something isn’t quite right is that we have a few warnings in the store related to us not making use of the task that is returned from invoking send .
— 20:31
The first place we have a warning is up in the store’s scope method, where we derive a store of child domain from a store of parent domain. In order to accomplish that we construct a new store whose reducer transforms a local action to a global one, sends that to the parent store, and then transforms the parent state to the local state: reducer: .init { localState, localAction, _ in isSending = true defer { isSending = false } self.send(fromLocalAction(localAction)) localState = toLocalState(self.state.value) return .none }, Result of call to ‘send(_:originatingFrom:)’ is unused
— 20:56
Currently we are ignoring the task that is returned from the send method. Remember that that task represents the lifetime of the effect executed by sending the action.
— 21:07
By discarding that task and returning a new effect that completes immediately we are losing the ability for scoped stores to await for effects to finish when sending actions. In fact, we can already see this manifest itself as a bug in our case studies.
— 21:21
Let’s go revert the app’s entry point back to the main root view: @main struct CaseStudiesApp: App { var body: some Scene { WindowGroup { RootView( store: Store( initialState: RootState(), reducer: rootReducer, environment: .live ) ) } } }
— 21:25
And let’s run the app again in order to see when “Effect finished!” is printed to the console
— 21:30
We see that it prints immediately upon drilling down. It does not wait until the long-living effect to compute the 50,000th prime finishes.
— 21:32
This is because we are scoping the entire case studies domain, which contains the state and actions for every case study in one big struct and enum, down to just the little bit of state that the effect basics case study actually cares about. By scoping we are secretly running this little reducer when sending an action, and by returning a .none effect we are losing out on the ability to observe the lifetime of the effect.
— 21:46
Luckily it’s easy enough to fix. What we need to do is return an effect whose lifetime matches that of the effect executed when sending the action. That is exactly the task returned from send represents, which means we can create a new effect that simply awaits for that task to finish: reducer: .init { localState, localAction, _ in isSending = true defer { isSending = false } let task = self.send(fromLocalAction(localAction)) localState = toLocalState(self.state.value) return .fireAndForget { await task.value } },
— 22:06
With that one small change we have fixed the warning and fixed the bug. If we run the case studies in the simulator we will see that “Effect finished!” is not printed to the console until the the 50,000th prime is computed.
— 22:21
We still have one warning in the store. It’s in the send method, where we sink on the effect returned from the reducer, and when the effect emits we send the action back into the store: receiveValue: { [weak self] effectAction in self?.send(effectAction, originatingFrom: action) } By calling send we are getting back a task, and we have to figure out what to do with it.
— 22:28
For one thing we could certainly just ignore it: _ = self?.send(effectAction, originatingFrom: action)
— 22:30
This would mean that effects started by this effect are not tied to the lifecycle of the originating effect. That may seem reasonable, but it also seems like it would be best to cancel as much as possible when the view goes away.
— 22:45
For example, if when the view appeared we kicked off an API request, and then when that request feed data back into the system we further kick off a long living effect, like say a location manager or a web socket connection, then we would probably expect that effect to be torn down when the view disappears.
— 23:01
It is possible to do this, and it doesn’t take much work, but we will leave it as an exercise for the viewer. Task cancellation
— 22:08
Instead, we will tackle a much trickier and more important flaw of our current solution. Remember that the main reason we wanted to tie the lifecycle of the effect to a task is so that when we cancel the task we also cancel the effect. This would mean that when we navigate away from the case studies screen while the nth prime is being computed, it should cancel that effect and prevent further actions from feeding back into the system.
— 23:32
Unfortunately this is not the case. If we run the application again, drill down into the effect basics case study, and then immediately navigate away, we will see in the logs that the reducer is still receiving actions from the effect
— 23:50
This is happening because although SwiftUI has cancelled the task we constructed, the cancellation of that task does not automatically trickle down all the way to the effect. We have to do extra work to make that happen.
— 24:01
To figure out where this work needs to happen, let’s trace back through the layers from the task in the view layer all the way back to where the effect is created. It starts with the task view modifier where we suspend while sending an action into the store: .task { await viewStore.send(.task).finish() }
— 24:14
When we navigate away from the screen, SwiftUI cancels this asynchronous context. That immediately trickles down to the asynchronous context in the finish method: public func finish() async { await self.rawValue.value }
— 24:18
However, as we saw in previous episodes on unstructured concurrency , cancellation does not automatically trickle down to unstructured tasks. We have to do extra work if we want the cancellation of this methods asynchronous context to affect the task we are holding onto.
— 24:34
In particular, we have to listen for cancellation of the method’s asynchronous context using withTaskCancellationHandler , and once we detect cancellation we can cancel the task we are holding onto under the hood: public func finish() async { await withTaskCancellationHandler { self.rawValue.cancel() } operation: { await self.rawValue.value } }
— 25:06
Cancelling this task goes directly directly to the task that is returned from the store’s send method: return Task { [tasks] in for task in tasks { await task.value } }
— 25:20
Again, cancelling this task has no affect on the array of unstructured tasks that we are processing. If we want cancellation of this task to cancel those tasks, we have to do it manually, again using withTaskCancellationHandler : return Task { [tasks] in await withTaskCancellationHandler { for task in tasks { task.cancel() } } operation: { for task in tasks { await task.value } } }
— 25:51
The tasks we are cancelling here are the ones that simply suspended “forever” in order to match the lifecycle of the effect started: let task = Task { _ = try? await Task .sleep(nanoseconds: NSEC_PER_SEC * 1_000_000_000) } tasks.append(task)
— 26:04
We might hope we can do something as simple as: let task = Task { try? await Task .sleep(nanoseconds: NSEC_PER_SEC * 1_000_000_000) effectCancellable.cancel() } Closure captures ‘effectCancellable’ before it is declared
— 26:13
But that doesn’t work because we are defining the task before the cancellable. We have a bit of a chicken-and-egg problem. The effect needs to be able to cancel the task and the task needs to be able to cancel the effect.
— 26:27
We fix this boxing the cancellable into a reference type that can be initialized at a later time and be referenced from an asynchronous context. Let’s quickly add a little private, generic box type at the bottom of the file: private final class Box<Value> { var value: Value init(_ value: Value) { self.value = value } }
— 26:41
And then we can box up a cancellable reference, initially nil , assign the cancellable once the effect is started, and then make sure to cancel the cancellable once the sleep finishes: let effectCancellable = Box<AnyCancellable?>(nil) let task = Task { _ = try? await Task .sleep(nanoseconds: NSEC_PER_SEC * 1_000_000_000) effectCancellable.value?.cancel() } … effectCancellable.value = effect.sink( … ) … if !didComplete { self.effectCancellables[uuid] = effectCancellable.value }
— 27:07
Further, we need to make the task @MainActor because the act of cancelling a cancellable causes the publisher to complete, and that must always happen on the main thread: let task = Task { @MainActor in _ = try? await Task .sleep(nanoseconds: NSEC_PER_SEC * 1_000_000_000) effectCancellable.value?.cancel() }
— 27:25
So, we now are able to detect when the view store task is cancelled in the view, which propagates back to the unstructured task spun up by the store’s Send method, which in turn is propagated all the way back to the effect that was kicked off when the action was sent. We would hope this just works, but unfortunately not yet.
— 27:43
If we run the application in the simulator, drill down to the case study, and then navigate back, we will see that the prime computation effect seems to still be emitting values. If we were to change the entry point of the application back to that slimmer navigation view like we did previously: @main struct CaseStudiesApp: App { var body: some Scene { WindowGroup { NavigationView { NavigationLink("Effect basics") { EffectsBasicsView( store: Store( initialState: EffectsBasicsState( count: 50_000 ), reducer: effectsBasicsReducer, environment: EffectsBasicsEnvironment( fact: .live, mainQueue: .main ) ) ) } } } } }
— 28:02
Then we will see the application works as we expect. We drill down, navigation back, and the effect stop sending data through.
— 28:12
This is yet another subtle problem having to do with store scoping. Currently we manage to return an effect whose lifetime matches the lifetime of the effect returned from the child feature: reducer: .init { localState, localAction, _ in isSending = true defer { isSending = false } let task = self.send(fromLocalAction(localAction)) localState = toLocalState(self.state.value) return .fireAndForget { await task.value } }
— 28:24
But now, when the child feature cancels its task, we need to propagate that to the parent, which we can do by yet again listening for cancellation in the fireAndForget , and then cancelling the task: reducer: .init { localState, localAction, _ in isSending = true defer { isSending = false } let task = self.send(fromLocalAction(localAction)) localState = toLocalState(self.state.value) return .fireAndForget { await withTaskCancellationHandler { task.cancel() } operation: { await task.value } } }
— 28:48
We have now finally achieved the cancellation behavior that we want. Let’s revert our changes to the entry point of the application, and run the app in the simulator. We can drill down, see that a stream of progress is being fed into the system, then navigate back out and see that the stream instantly stops.
— 29:10
It’s a little intense that we had to explicitly handle cancellation in 3 different places, first the ViewStoreTask , then the task returned by Store.send , and now in Store.scope . However, these are library-levels concerns, and it’s only necessary because we are doing a lot of bridging from non-concurrency worlds to the concurrency world. In a future where we completely disentangle the library from Combine, a lot of these operations can be expressed more naturally where cancellation will be handled implicitly rather than explicitly.
— 29:40
There’s one additional improvement we want to make, this time to our demo code, not the library. Although the effect is being properly cancelled when navigating away, which means we don’t get any future emissions from the effect, the effect is still actually running. We can see this by putting a print just before we try sending an action: if isPrime(prime) { primeCount += 1 } else if prime.isMultiple(of: 1_000) { print("Progress", Double(primeCount) / Double(number)) await send( .nthPrime( .progress(Double(primeCount) / Double(number)) ), animation: .default ) await Task.yield() }
— 30:07
If we run this, drill down and navigate back, we see that although no new actions are being sent into the store, the effect is still running. This is because there are no throwing functions inside this effect that could detect cancellation and interrupt execution. Instead, it is on us to cooperatively cancel by checking the current tasks cancellation status.
— 30:32
We can do this by checking if the task is cancelled for each iteration of the while loop: while primeCount < number, !Task.isCancelled { … }
— 30:40
And now if we run again and repeat the script we will see the effect no longer runs after navigating away.
— 30:52
This is a huge achievement. We now have a way of tying the lifetime of an effect to the lifetime of a view. When the view appears we start off the effect, and when the view disappears we automatically tear it down. In future episodes we will be going deep into navigation in the Composable Architecture, and we will uncover even more tools for tying effect lifetimes to feature lifetimes. Testing
— 31:13
Things are looking really great, but we’ve now spent a lot of time improving the library without talking about tests at all. Testability is one of the most important aspects of the library, so we would never want to introduce functionality that hurts testing.
— 31:31
There is one sign that things are maybe not quite right with testing. There is currently a warning in the test store for an unused return value: self.store.send(.init(origin: .send(action), …)) Result of call to ‘send(_:originatingFrom:)’ is unused
— 31:43
The store’s send method now returns a task, and we aren’t doing anything with it. To see why it’s problematic to ignore this task, let’s write a quick test for a new little toy domain that kicks off a long-living effect in a task action: func testLongLivingEffect() { enum Action { case task } let store = TestStore( initialState: 0, reducer: Reducer<Int, Action, Void> { _, _, _ in .run { _ in try? await Task.sleep(nanoseconds: …) } }, environment: () ) store.send(.task) }
— 32:05
Although a toy example, this does represent a common real life pattern, in which when a view appears we want to start up an effect that runs for as long as the view is visible. Such an effect could be a timer, or listening for notifications, or a connection to a web socket, or who knows what else. In such cases we don’t need an ancillary action that is sent to tear down the effect, because it should happen automatically when the view goes away.
— 32:30
However, that does mean we don’t have an action to send in this test to stop the effect, which means we get a test failure when we run it: An effect returned for this action is still running. It must complete before the end of the test. …
— 32:38
We could of course add a new action to take care of stopping the effect, but that is a little strange to do because we don’t really need it for the view. It’s only needed for tests. In the view we are able to tie the lifetime of the effect to the lifetime of the view, and once the view goes away the effect will be automatically cancelled.
— 32:45
What if we had access to the task that represented the lifetime of the effect in the test, so that we could just cancel it whenever we want to represent the idea of the view going away: let task = store.send(.task) task.cancel()
— 33:00
To get access to the task we need to return it from the test store’s send method: public func send(…) -> Task<Void, Never> { … let task = self.store.send(…) … return task }
— 33:17
Now when we run the test it still fails unfortunately. Although we have canceled the task it doesn’t seem like enough time has passed for everything to be registered with the test store. We need to wait a little more time, and there’s a really easy way to do this because we can just wait for the task to complete, which should be very fast: let task = store.send(.task) task.cancel() await task.value
— 33:42
This now passes, and does so deterministically because we can even run this 1,000 times and it passes 100% of the time: Executed 1000 tests, with 0 failures (0 unexpected) in 1.711 (2.360) seconds
— 33:57
It’s a little annoying to have to remember to both cancel the task and await it, so we can cook up a type that wraps a task and performs all of that in one go, kinda of like what we did for ViewStoreTask : public struct TestStoreTask { let rawValue: Task<Void, Never> public func cancel() async { self.rawValue.cancel() await self.rawValue.value } }
— 34:32
And then we can update the test store’s send to use this type: public func send(…) -> TestStoreTask { … return TestStoreTask(rawValue: task) }
— 34:46
And now we can get our test passing more simply, and it’s still deterministic: let task = store.send(.task) await task.cancel()
— 34:59
This is also just the tool we need to fix a test that we were having trouble with last episode.
— 35:05
Previously in our test that exercises computing a prime, we saw that we had to sprinkle in some yields in order to give the effect enough time to fully finish. Now that we have a handle on the actual task representing the effect, we can just await it’s value: func testNthPrime() async throws { let store = TestStore( initialState: EffectsBasicsState(count: 200), reducer: effectsBasicsReducer, environment: .unimplemented ) let task = store.send(.nthPrimeButtonTapped) … await task.rawValue.value }
— 35:38
Even better, we can add a method on TestStoreTask that represents suspending until it finishes, again just like ViewStoreTask : public struct TestStoreTask { … public func finish() async { await task.value } }
— 35:56
And so now we can just do: await task.finish()
— 35:58
And this test now passes deterministically, 100% of the time.
— 36:11
It would also be possible to support a finish() operation on the entire test store so that you didn’t have to get a handle on any particular task and could just wait for all effects to fully tear down: store.send(.nthPrimeButtonTapped) … await store.finish()
— 36:33
We won’t do that now, but it’s easy enough to do, and it will be available in the library.
— 36:38
One thing we should fix is this warning we introduced: store.send(.nthPrimeButtonTapped) Result of call to ‘ send(_:_:file:line:) ’ is unused
— 36:43
By marking the test store task discardable: @discardableResult public func send(…) -> TestStoreTask { … }
— 36:52
So, guided by the warning we received in the test store we have now made it possible to properly test features that make use of the new .task view modifier without sacrificing the strong, exhaustive test guarantees that the library gives us. Conclusion
— 37:05
And this concludes our series of episodes on bringing more of Swift’s concurrency features to the Composable Architecture. We now have the tools for spinning up asynchronous contexts in effects and feed data back into the system, as well as the tools for introducing timing-based asynchronicity into effects, and best of all, everything is still 100% testable. We did not have to sacrifice testability just to get access to all of Swift’s fancy new concurrency tools.
— 37:31
There is one more topic we want to discuss, which we will be doing next time. We feel that these changes to the library are so significant, in fact it’s the biggest update we have had to the library since its first release over 2 years ago, that we should spend a little more time showing how the new tools improve everyday, real world code.
— 37:48
So, we are going to improve a few more of the case studies and demo applications in the repo, and we are going to take a look at our word game, isowords . It’s a very large, complex application built entirely in the Composable Architecture. It has lots of very complex dependencies and effects, and currently we often have to bend over backwards in order to chain together Combine operations to express the things we want.
— 38:10
Well, no more. These new concurrency tools can massively simplify how we construct complex effects, making the code far shorter and far easier to understand.
— 38:19
So until next time… References Collection: Concurrency Brandon Williams & Stephen Celis Note Swift has many tools for concurrency, including threads, operation queues, dispatch queues, Combine and now first class tools built directly into the language. We start from the beginning to understand what the past tools excelled at and where they faultered in order to see why the new tools are so incredible. http://pointfree.co/collections/concurrency Downloads Sample code 0199-tca-concurrency-pt5 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .