TCA & SwiftUI view lifecycle
SwiftUI's view lifecycle and TCA's store lifetime don't always align, and that mismatch can cause cleanup side effects to silently never run.
The Composable Architecture (TCA) is a library for building applications in a consistent and understandable way. It centralises state and side effects in a reducer, making behaviour easy to test and reason about. All interactions with the outside world (networking, timers, system APIs) are modelled as dependencies injected into the reducer.
Brightness Example
Imagine we want to set the display brightness to maximum while a specific view is on screen, and restore it when the view disappears. This is a side effect that needs to be tied to the view's lifecycle.
We start with a fictional dependency client.
struct BrightnessClient {
var setToMax: () -> Void
var reset: () -> Void
}
The natural first instinct is to use onAppear and onDisappear to drive these side effects through the store. When the view appears, we send .onAppear to trigger setToMax, and when it disappears, we send .onDisappear to call reset.
@Reducer
struct MyFeature {
@ObservableState
struct State: Equatable {}
enum Action {
case onAppear
case onDisappear
}
@Dependency(\.brightnessClient) var brightnessClient
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
brightnessClient.setToMax()
return .none
case .onDisappear:
brightnessClient.reset()
return .none
}
}
}
}
struct MyView: View {
let store: StoreOf<MyFeature>
var body: some View {
Text("Hello")
.onAppear { store.send(.onAppear) }
.onDisappear { store.send(.onDisappear) }
}
}
However, this approach can have a subtle problem. When the view is dismissed, SwiftUI deallocates the store before onDisappear fires. TCA catches this and emits a runtime warning:
An "ifLet" at "..." received a presentation action when destination state was absent.
Action:
onDisappear
This means brightnessClient.reset() never gets called, leaving the screen stuck at maximum brightness.
One workaround is to call BrightnessClient directly from the view, bypassing the store entirely:
struct MyView: View {
let store: StoreOf<MyFeature>
@Dependency(\.brightnessClient) var brightnessClient
var body: some View {
Text("Hello")
.onAppear { brightnessClient.setToMax() }
.onDisappear { brightnessClient.reset() }
}
}
This works reliably: SwiftUI still owns the view when onDisappear fires. But it is a discouraged pattern because it moves side effects out of the reducer and into the view. That makes the behaviour impossible to test with TCA's TestStore, since it only observes actions and state changes, not direct dependency calls made from the view layer.
The solution
The key insight is that TCA reliably cancels long-running effects when the store is deallocated. We can exploit this by starting a cancellable effect on .onAppear that keeps running until it is cancelled. And using AsyncStream's onTermination handler as the teardown hook.
case .onAppear:
brightnessClient.setToMax()
return .run { _ in
let stream = AsyncStream<Never> { continuation in
continuation.onTermination = { _ in
brightnessClient.reset()
}
}
// Await the stream forever. It only ends when the task is cancelled,
// which triggers onTermination and calls reset().
for await _ in stream {}
}
.cancellable(id: CancelID.brightness)
When the store is deallocated, TCA cancels all in-flight effects. Cancelling the task finishes the AsyncStream, which triggers its onTermination closure and calls brightnessClient.reset() without relying on onDisappear ever being delivered.
Conclusion
I only saw this pattern mentioned in some forum posts and discussions as a workaround but never as a fully functional solution.
So this pattern is a neat way to tie reducer-managed side effects to the view's lifetime. The logic stays in the reducer where it is testable, and the cleanup is guaranteed regardless of how the view is dismissed.