SwiftUI Patterns

Pull-to-Refresh with .refreshable

May 13, 2026
5 min read
Featured image for blog post: Pull-to-Refresh with .refreshable

The .refreshable modifier adds native pull-to-refresh with minimal code. Here's how to implement it properly.

Follow along with the code: iOS-Practice on GitHub

Basic Implementation

struct PullToRefreshExerciseView: View {
    @State private var posts: [Post] = []
    @State private var isLoading = false
    @State private var lastRefresh: Date?

    var body: some View {
        List {
            if let lastRefresh = lastRefresh {
                Section {
                    HStack {
                        Text("Last updated")
                        Spacer()
                        Text(lastRefresh, style: .relative)
                            .foregroundColor(.secondary)
                    }
                    .font(.caption)
                }
            }

            Section("Posts") {
                ForEach(posts) { post in
                    PostRowView(post: post)
                }
            }
        }
        .refreshable {
            await loadPosts()
        }
        .task {
            await loadPosts()
        }
    }

    private func loadPosts() async {
        isLoading = true
        do {
            posts = try await MockAPIService.shared.fetchPosts()
            lastRefresh = Date()
        } catch {
            // Handle error
        }
        isLoading = false
    }
}

How .refreshable Works

The modifier takes an async closure:

.refreshable {
    await loadPosts()
}

SwiftUI automatically:

  • Shows the pull-to-refresh indicator
  • Waits for your async work to complete
  • Hides the indicator when done

No need to manage the refresh state manually.

Combining with Initial Load

Use .task for initial load and .refreshable for manual refresh:

.task {
    await loadPosts()
}
.refreshable {
    await loadPosts()
}

Both can call the same function.

Showing Last Refresh Time

Track when data was last refreshed:

@State private var lastRefresh: Date?

private func loadPosts() async {
    // ... load data ...
    lastRefresh = Date()
}

Display it with relative formatting:

Text(lastRefresh, style: .relative)
// Shows: "2 minutes ago", "1 hour ago", etc.

Error Handling with Refresh

Show errors while keeping stale data visible:

@State private var error: Error?

var body: some View {
    List { ... }
    .refreshable {
        await loadPosts()
    }
    .overlay {
        if isLoading && posts.isEmpty {
            ProgressView("Loading...")
        } else if let error, posts.isEmpty {
            ContentUnavailableView {
                Label("Error", systemImage: "exclamationmark.triangle")
            } description: {
                Text(error.localizedDescription)
            }
        }
    }
}

If refresh fails but old data exists, users still see content.

Post Row View

struct PostRowView: View {
    let post: Post

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(post.title)
                .font(.headline)
                .lineLimit(2)

            Text(post.body)
                .font(.subheadline)
                .foregroundColor(.secondary)
                .lineLimit(2)

            HStack {
                Label("\(post.likes)", systemImage: "heart.fill")
                    .foregroundColor(.red)
                    .font(.caption)

                Spacer()

                Text(post.createdAt, style: .relative)
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
        }
        .padding(.vertical, 4)
    }
}

Interview Tip

When discussing .refreshable:

  • It's async-native—no completion handlers
  • The indicator dismisses automatically when the closure returns
  • Works with any scrollable view, not just List
  • Combine with .task for initial load

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch