How to use Refreshable modifier in SwiftUI | Modern Swift Concurrency #15
SwiftUI's .refreshable modifier gives users a pull-to-refresh gesture that is directly wired to async/await — the spinning indicator stays visible for exactly as long as your async function takes to complete, with no timers, no manual show/hide, and no completion callbacks required.
What You'll Learn
- How
.refreshableintegrates withasync/awaitto drive the pull-to-refresh indicator automatically - Why
.refreshablerequires anasyncclosure rather than starting a newTaskinside it - How
@MainActoron the view model eliminates the need to dispatch back to the main thread after a fetch - How the same
loadData()method can be called from both.task(initial load) and.refreshable(user-triggered refresh) without duplicating logic - Why errors should be caught inside the view model rather than propagated to the view
Mental Model
Think of .refreshable as a valet stand at a restaurant. When a customer pulls down (pull-to-refresh), they hand their ticket to the valet (the async closure runs). The valet keeps the ticket window open (spinner stays visible) until the car arrives (the await resolves). The moment the car pulls up (async work completes), the valet hands over the keys and closes the window (spinner disappears). The customer never sees the internal work — they just see the spinner while they wait.
The key insight is that SwiftUI owns the spinner. You do not show or hide it — you simply await inside the .refreshable closure, and SwiftUI keeps the indicator running for the entire duration of that await. This makes the UI timing automatic and correct by construction.
Detailed Explanation
Before .refreshable, pull-to-refresh in UIKit required UIRefreshControl, callback-based notification of completion, and manual calls to endRefreshing(). The exact moment to call endRefreshing() was error-prone — call it too early and the spinner disappears while the data is still loading; forget it and the spinner never stops.
SwiftUI's .refreshable modifier solves this by accepting an async closure. The modifier calls that closure when the user triggers a refresh, suspends at every await inside it, and dismisses the refresh indicator only after the closure's async work completes. The timing is exact and automatic because it is driven by Swift's concurrency mechanism rather than explicit callback coordination.
A critical detail: .refreshable expects an async closure. You should await your data-loading method directly inside it — do not wrap the call in Task { }. If you create a new Task inside .refreshable, the outer async closure returns immediately (the Task is fire-and-forget), and SwiftUI dismisses the spinner before the data arrives. The .refreshable closure itself becomes your task boundary.
When the view model is annotated with @MainActor, every @Published mutation inside loadData() is already on the main thread. There is no need to dispatch back to main for the UI update — the annotation handles it. This is the ergonomic advantage: items = try await manager.getData() assigns directly, and SwiftUI picks up the change instantly.
The sample also shows loading data from two places — .task for the initial load on view appear, and .refreshable for subsequent user-triggered refreshes. Both call the same viewModel.loadData() method. This avoids duplication and ensures the loading logic lives in exactly one place: the view model.
Code Structure
RefreshableBootcamp.swift contains three types: RefreshableDataService (simulates a slow network call with Task.sleep), RefreshableBootcampViewModel (handles state and error boundaries, annotated @MainActor), and RefreshableBootcamp (the SwiftUI view that wires .task for initial load and .refreshable for pull-to-refresh).
Complete Code
RefreshableBootcamp.swift
import SwiftUI
// Simulates a slow network service — `async throws` signature mirrors a real URLSession call.
final class RefreshableDataService {
func getData() async throws -> [String] {
try? await Task.sleep(nanoseconds: 5_000_000_000) // 5-second simulated delay
return ["Apple", "Orange", "Banana"].shuffled() // shuffled to show visible change on refresh
}
}
// @MainActor: all @Published mutations are automatically on the main thread.
@MainActor
final class RefreshableBootcampViewModel: ObservableObject {
// private(set): view can read items, only the view model can write them.
@Published private(set) var items: [String] = []
let manager = RefreshableDataService()
// Shared load function — called from both .task (initial) and .refreshable (user refresh).
func loadData() async {
do {
items = try await manager.getData() // direct assignment; @MainActor ensures main thread
} catch {
print(error) // in production, set an @Published errorMessage property here
}
}
}
struct RefreshableBootcamp: View {
@StateObject private var viewModel = RefreshableBootcampViewModel()
var body: some View {
NavigationStack {
ScrollView {
VStack {
ForEach(viewModel.items, id: \.self) { item in
Text(item)
.font(.headline)
}
}
}
// .refreshable: the closure is async. SwiftUI keeps the spinner visible
// until `await viewModel.loadData()` fully resolves.
.refreshable {
await viewModel.loadData() // do NOT wrap this in Task { } — that breaks the spinner timing
}
.navigationTitle("Refreshable")
// .task: runs on view appear, cancelled automatically when view disappears.
.task {
await viewModel.loadData() // same method, same logic — no duplication
}
}
}
}
struct RefreshableBootcamp_Previews: PreviewProvider {
static var previews: some View {
RefreshableBootcamp()
}
}Code Walkthrough
getData() async throws— Theasync throwssignature is the standard for any function that does I/O.throwslets the view model'sdo/catchblock handle errors in one place.try?onTask.sleepis a convenience shortcut that ignores the cancellation error — in production you mighttryand propagate the cancellation properly.@MainActor final class RefreshableBootcampViewModel— The whole-class@MainActorannotation means that whenloadData()resumes after theawait, the assignmentitems = try await manager.getData()runs on the main actor automatically. Without this annotation, you would needawait MainActor.run { self.items = ... }after everyawait.@Published private(set) var items—private(set)enforces that only the view model can changeitems. The view reads it reactively via SwiftUI's observation mechanism, but cannot set it directly. This is the correct MVVM boundary..refreshable { await viewModel.loadData() }— This is the correct pattern. Theasyncclosure provided to.refreshablesuspends at theawait, keeping the pull-to-refresh indicator visible. WhenloadData()finishes (success or error, because we catch errors inside), the closure returns and SwiftUI hides the indicator..task { await viewModel.loadData() }— Calling the same method from.taskreuses the same loading logic for the initial load. The.taskmodifier's task is tied to the view's lifetime — it is cancelled automatically when the view disappears, which also cancels theloadData()call at its nextawaitsuspension point.do/catchinsideloadData()— Error handling in the view model, not in the view. The view's.refreshableclosure does not need to handle errors; it simplyawaits the method. IfgetData()throws, thecatchblock inloadData()handles it. In a real app, thecatchblock would set an@Published errorMessagethat the view displays, rather than just printing.
Common Mistakes
Mistake: Wrapping the async call in Task { } inside .refreshable.
.refreshable {
Task { await viewModel.loadData() } // WRONG: spinner disappears immediately
}The .refreshable closure itself is async. Creating a Task inside it causes the closure to return immediately — it fires the task and exits. SwiftUI sees the closure return and dismisses the spinner right away, before the data arrives. Always await directly inside .refreshable.
Mistake: Swallowing errors silently and leaving the user staring at an empty or stale list.
The print(error) in the sample is a placeholder. In production, if getData() throws, users need feedback. Set an @Published var errorMessage: String? in the view model and display it as an alert or an inline error state. Silently ignoring errors after a user-initiated refresh is a poor user experience.
Mistake: Showing a manual loading indicator alongside .refreshable.
Some developers add an isLoading flag and show a ProgressView while also using .refreshable. This creates a confusing double-indicator. The .refreshable spinner is SwiftUI's built-in loading indicator for this pattern — trust it. Reserve explicit isLoading state for the initial load via .task, where the pull-to-refresh spinner is not shown.
Key Takeaways
.refreshableaccepts anasyncclosure —awaityour work directly inside it without wrapping inTask, or the spinner timing breaks.- SwiftUI owns the pull-to-refresh indicator and keeps it visible for the exact duration of the
asyncclosure — no manual show/hide needed. - Share the same view model method between
.task(initial load) and.refreshable(user refresh) to avoid logic duplication and keep the loading behavior consistent.
Last updated: June 27, 2026