If you’ve used The Composable Architecture (TCA) with SwiftUI’s .refreshable
modifier, you’ve likely noticed an annoying issue: the refresh indicator dismisses immediately instead of waiting for your data to load. Today, let’s fix that.
The Problem
Here’s what typically happens when implementing pull-to-refresh with TCA:
.refreshable {
Task { @MainActor in
store.send(.view(.onPullToRefresh))
}
}
The refresh indicator appears, you release it, and… it immediately disappears. Meanwhile, your data is still loading in the background. This provides poor user feedback - users can’t tell if their refresh action actually triggered anything.
Why Does This Happen?
TCA’s send()
method is intentionally fire-and-forget. When you send an action:
- State mutations happen immediately and synchronously
- Effects run separately and asynchronously
send()
returns immediately without waiting
This is a core architectural principle of TCA - it ensures predictable state mutations and makes testing deterministic. But it doesn’t play nicely with SwiftUI’s .refreshable
, which expects an async function that completes when the refresh is done.
The Solution
We need to bridge the gap between TCA’s synchronous actions and SwiftUI’s async expectations. Here’s a simple extension that does exactly that:
extension Store {
@MainActor
func send(_ action: Action, while isInFlight: @escaping (State) -> Bool) async {
self.send(action)
await withCheckedContinuation { continuation in
var cancellable: AnyCancellable?
cancellable = self.publisher
.filter { !isInFlight($0) }
.prefix(1)
.sink { _ in
continuation.resume()
_ = cancellable // Keep reference to avoid deallocation
}
}
}
}
Simply replace your existing refreshable:
.refreshable {
await store.send(.view(.onPullToRefresh), while: { state in
state.data.isRefreshing // Your refreshing state
})
}
Why Isn’t This Built Into TCA?
The extension comes from Point-Free’s Episode #154 (around the 12:00 mark), and it’s been battle-tested in production apps.
While this is a simple extension, it’s not available directly in TCA. After digging through GitHub discussions and Point-Free episodes:
- Architectural Purity:
send()
must remain synchronous and fire-and-forget for predictable state mutations. - Not Universal: Timers and long-running observations shouldn’t block.
- Simplicity: At 15 lines, it’s simple enough to implement yourself. Point-Free demonstrates patterns and lets the community adopt them.
- Still Exploring: The maintainers want more exploration before committing to an API (even after many years..)