read

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:

  1. State mutations happen immediately and synchronously
  2. Effects run separately and asynchronously
  3. 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:

  1. Architectural Purity: send() must remain synchronous and fire-and-forget for predictable state mutations.
  2. Not Universal: Timers and long-running observations shouldn’t block.
  3. Simplicity: At 15 lines, it’s simple enough to implement yourself. Point-Free demonstrates patterns and lets the community adopt them.
  4. Still Exploring: The maintainers want more exploration before committing to an API (even after many years..)

Image

@samwize

¯\_(ツ)_/¯

Back to Home