Skip to content

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 .refreshable integrates with async/await to drive the pull-to-refresh indicator automatically
  • Why .refreshable requires an async closure rather than starting a new Task inside it
  • How @MainActor on 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

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

  1. getData() async throws — The async throws signature is the standard for any function that does I/O. throws lets the view model's do/catch block handle errors in one place. try? on Task.sleep is a convenience shortcut that ignores the cancellation error — in production you might try and propagate the cancellation properly.

  2. @MainActor final class RefreshableBootcampViewModel — The whole-class @MainActor annotation means that when loadData() resumes after the await, the assignment items = try await manager.getData() runs on the main actor automatically. Without this annotation, you would need await MainActor.run { self.items = ... } after every await.

  3. @Published private(set) var itemsprivate(set) enforces that only the view model can change items. The view reads it reactively via SwiftUI's observation mechanism, but cannot set it directly. This is the correct MVVM boundary.

  4. .refreshable { await viewModel.loadData() } — This is the correct pattern. The async closure provided to .refreshable suspends at the await, keeping the pull-to-refresh indicator visible. When loadData() finishes (success or error, because we catch errors inside), the closure returns and SwiftUI hides the indicator.

  5. .task { await viewModel.loadData() } — Calling the same method from .task reuses the same loading logic for the initial load. The .task modifier's task is tied to the view's lifetime — it is cancelled automatically when the view disappears, which also cancels the loadData() call at its next await suspension point.

  6. do/catch inside loadData() — Error handling in the view model, not in the view. The view's .refreshable closure does not need to handle errors; it simply awaits the method. If getData() throws, the catch block in loadData() handles it. In a real app, the catch block would set an @Published errorMessage that the view displays, rather than just printing.

Common Mistakes

Mistake: Wrapping the async call in Task { } inside .refreshable.

swift
.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

  • .refreshable accepts an async closure — await your work directly inside it without wrapping in Task, or the spinner timing breaks.
  • SwiftUI owns the pull-to-refresh indicator and keeps it visible for the exact duration of the async closure — 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

Released under the MIT License.