What's the Issue?
To implement pull-to-refresh in SwiftUI, you need to use the .refreshable modifier, but here is the problem:
- If you simply add the
.refreshablemodifier to yourScrollorListview and send an action to perform the refresh request, you'll notice that the loader disappears immediately.
.refreshable {
send(.refresh)
}
This happens because we don't wait for the completion of the request. To address this, we must call .finish() on our send call and await it:
.refreshable {
await send(.refresh).finish()
}

Adding a Refresh Completion Notification
Another useful thing is to get notified when the refresh has finished. To achieve this, let's add a state variable that will hold true while the refresh is in progress. We'll also add an action, .refreshFinished, which will be sent when the refresh scope is completed. We'll then update our state accordingly.
State
@ObservableState
struct State: Hashable, Sendable {
//...
var isRefreshing: Bool = false
}
public enum Action : Equatable, Sendable, ViewAction {
case view(ViewAction)
public enum ViewAction: Equatable, Sendable {
case refreshFinished
case refresh
}
}
//later in reducer
case .refreshFinished:
state.isRefreshing = false
return .none
case .refresh:
state.isRefreshing = true
return .run { _ in
try await clock.sleep(for: .seconds(2))
}
To send refreshFinished, we'll utilize the defer keyword, which is basically the same as try/finally block in other languages.
View
var body: some View {
List {
ForEach(store.items, id: \.self) { item in
Text(item)
}
}
.refreshable {
defer { send(.refreshFinished) }
await send(.refresh).finish()
}
}
Implementing Refresh Cancellation
Let's add another feature to allow users to cancel the refresh. Since we now have a state variable, we can simply add a cancel button whenever the refresh is in progress and send a .cancelRefreshTapped action.
if store.isRefreshing {
withAnimation(.easeIn) {
Button("Cancel") {
send(.cancelRefreshTapped)
}
}
}
We'll also need to create a CancelID to identify which request to cancel
private enum CancelID { case refreshRequest }
and mark the refresh effect as .cancellable, providing the appropriate CancelID.
case .refresh:
state.isRefreshing = true
return .run { _ in
try await clock.sleep(for: .seconds(2))
}
.cancellable(id: CancelID.refreshRequest)
Finally, we need to cancel the request when the .cancelRefreshTapped action is received.
case .cancelRefreshTapped:
return .cancel(id: CancelID.refreshRequest)
Conclusion
That's it! We now have a fully functional pull-to-refresh implementation with proper handling of loading states, completion notifications, and cancellation.
Full Code can be found here
Similar example from TCA Case Studies repo
Feedback Welcome
If you enjoyed this post, please leave a reaction. If you have any alternative approaches to implementing this, I would appreciate your comments.
About This Blog
I started this blog to share the things I learn and believe can be helpful to others, so follow for more. Thanks (˶ᵔ ᵕ ᵔ˶)

Top comments (0)