Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 089 / 100

Pull to refresh and pagination: best practices explained

Fresh data in lists.

Expona Admin8 min read

Pull to refresh and pagination: best practices explained

Lists that load fresh data on demand and seamlessly paginate through large datasets are table stakes for modern iOS apps. We'll walk through implementing pull to refresh and pagination with SwiftUI's async patterns, handling loading states gracefully, and cancelling tasks when views disappear—patterns we've refined across e-commerce and SaaS products handling millions of rows.

.refreshable with async functions

SwiftUI's .refreshable modifier is designed for async/await workflows. It attaches a refresh action to a List or ScrollView and automatically shows a loading indicator when the user pulls down. The key is that your refresh function must be an async function; the system awaits its completion before dismissing the spinner.

Here's a real-world example from an e-commerce app fetching the latest orders:

swift
@MainActor
final class OrderListViewModel: ObservableObject {
    @Published var orders: [Order] = []
    @Published var isLoading = false
    private let apiClient: APIClient
    
    init(apiClient: APIClient) {
        self.apiClient = apiClient
    }
    
    func refresh() async {
        isLoading = true
        defer { isLoading = false }
        
        do {
            orders = try await apiClient.fetchOrders(page: 1, limit: 20)
        } catch {
            print("Refresh failed: \(error)")
        }
    }
}

struct OrderListView: View {
    @StateObject private var viewModel = OrderListViewModel(
        apiClient: APIClient.shared
    )
    
    var body: some View {
        List(viewModel.orders) { order in
            OrderRow(order: order)
        }
        .refreshable {
            await viewModel.refresh()
        }
    }
}

The .refreshable modifier handles the pull gesture automatically. SwiftUI manages the refresh spinner UI—you don't need to draw a loading indicator yourself. The async function runs on the main thread (note the @MainActor annotation), so all state updates are thread-safe.

In production, you'll want to reset pagination state when refreshing. If your user pulls to refresh while on page 3, you must reset back to page 1 and clear the existing data before fetching:

swift
func refresh() async {
    currentPage = 1
    orders.removeAll()
    isLoading = true
    defer { isLoading = false }
    
    do {
        orders = try await apiClient.fetchOrders(page: 1, limit: 20)
    } catch {
        print("Refresh failed: \(error)")
    }
}

Load more when the last item appears

Pagination requires detecting when the user scrolls near the end of the list. Rather than relying on scroll view delegates, SwiftUI lets us observe when the last item appears using the .onAppear modifier on individual rows.

Build pagination into your view model:

swift
@MainActor
final class PaginatedOrderListViewModel: ObservableObject {
    @Published var orders: [Order] = []
    @Published var isLoadingMore = false
    @Published var hasMorePages = true
    
    private var currentPage = 1
    private let pageSize = 20
    private let apiClient: APIClient
    
    init(apiClient: APIClient) {
        self.apiClient = apiClient
        Task {
            await loadInitial()
        }
    }
    
    func loadInitial() async {
        isLoadingMore = true
        defer { isLoadingMore = false }
        
        do {
            currentPage = 1
            orders = try await apiClient.fetchOrders(
                page: currentPage,
                limit: pageSize
            )
            hasMorePages = orders.count == pageSize
        } catch {
            print("Initial load failed: \(error)")
        }
    }
    
    func loadMore() async {
        guard !isLoadingMore && hasMorePages else { return }
        
        isLoadingMore = true
        defer { isLoadingMore = false }
        
        do {
            currentPage += 1
            let newOrders = try await apiClient.fetchOrders(
                page: currentPage,
                limit: pageSize
            )
            orders.append(contentsOf: newOrders)
            hasMorePages = newOrders.count == pageSize
        } catch {
            print("Load more failed: \(error)")
            currentPage -= 1
        }
    }
}

In your view, attach a task to the last row. When it appears, trigger the load:

swift
struct PaginatedOrderListView: View {
    @StateObject private var viewModel = PaginatedOrderListViewModel(
        apiClient: APIClient.shared
    )
    
    var body: some View {
        List(viewModel.orders) { order in
            OrderRow(order: order)
                .onAppear {
                    if viewModel.orders.last?.id == order.id {
                        Task {
                            await viewModel.loadMore()
                        }
                    }
                }
        }
        .refreshable {
            await viewModel.loadInitial()
        }
    }
}

The guard statement in loadMore() prevents duplicate requests. If isLoadingMore is already true or hasMorePages is false, we exit early. When the API returns fewer items than the page size, we know we've reached the end.

Show loading states

Users need visual feedback during data fetches. Show spinners, disabled buttons, or skeleton screens. The difference between a polished app and a frustrating one often comes down to clear loading indicators.

Here's a view that displays different states:

swift
struct PaginatedOrderListView: View {
    @StateObject private var viewModel = PaginatedOrderListViewModel(
        apiClient: APIClient.shared
    )
    
    var body: some View {
        ZStack {
            List {
                ForEach(viewModel.orders) { order in
                    OrderRow(order: order)
                        .onAppear {
                            if viewModel.orders.last?.id == order.id {
                                Task {
                                    await viewModel.loadMore()
                                }
                            }
                        }
                }
                
                if viewModel.isLoadingMore {
                    HStack {
                        Spacer()
                        ProgressView()
                        Spacer()
                    }
                    .frame(height: 44)
                    .listRowSeparator(.hidden)
                }
            }
            .refreshable {
                await viewModel.loadInitial()
            }
            
            if viewModel.orders.isEmpty && !viewModel.isLoadingMore {
                VStack(spacing: 12) {
                    Text("No orders yet")
                        .font(.headline)
                    Text("Check back soon for updates")
                        .font(.caption)
                        .foregroundColor(.secondary)
                }
            }
        }
        .navigationTitle("Orders")
    }
}

This approach shows:

  • A loading spinner at the bottom of the list when fetching more pages
  • An empty state message when no orders exist and we're not loading
  • The list itself updates reactively as orders arrive

For larger datasets or slower networks, skeleton screens (placeholder rows with shimmer animations) improve perceived performance. Libraries like Shimmer or custom implementations using redacted(reason: .placeholder) work well here.

Cancel tasks when leaving the screen

When a user navigates away from a list view, any in-flight network requests should be cancelled. Leaving them running wastes bandwidth, drains battery, and can cause crashes if the view is deallocated while a task completes.

SwiftUI's task cancellation model uses structured concurrency. Tasks created with .task or .task(id:) are automatically cancelled when the view disappears:

swift
struct OrderListView: View {
    @StateObject private var viewModel = PaginatedOrderListViewModel(
        apiClient: APIClient.shared
    )
    
    var body: some View {
        List(viewModel.orders) { order in
            OrderRow(order: order)
                .onAppear {
                    if viewModel.orders.last?.id == order.id {
                        Task {
                            await viewModel.loadMore()
                        }
                    }
                }
        }
        .refreshable {
            await viewModel.loadInitial()
        }
        .task {
            await viewModel.loadInitial()
        }
    }
}

When you use .task, SwiftUI automatically cancels the task if the view disappears. The network layer must respect cancellation—using URLSession's built-in cancellation support or checking Task.isCancelled in loops:

swift
class APIClient {
    func fetchOrders(page: Int, limit: Int) async throws -> [Order] {
        var request = URLRequest(url: ordersURL)
        request.timeoutInterval = 10
        
        let (data, response) = try await URLSession.shared.data(for: request)
        
        guard let httpResponse = response as? HTTPURLResponse,
              200..<300 ~= httpResponse.statusCode else {
            throw APIError.invalidResponse
        }
        
        return try JSONDecoder().decode([Order].self, from: data)
    }
}

URLSession automatically handles cancellation when the task is cancelled. If you're building a custom fetch with loops or delays, check Task.isCancelled:

swift
func fetchWithRetry() async throws -> [Order] {
    var attempt = 0
    let maxAttempts = 3
    
    while attempt < maxAttempts {
        if Task.isCancelled {
            throw CancellationError()
        }
        
        do {
            return try await fetchOrders(page: 1, limit: 20)
        } catch {
            attempt += 1
            if attempt < maxAttempts {
                try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
            }
        }
    }
    
    throw APIError.maxRetriesExceeded
}

This pattern ensures we don't waste resources on failed requests after the view is gone.

Common mistakes

Forgetting to reset pagination on refresh. Users pull to refresh expecting a fresh list, not a merged view. Always set currentPage = 1 and clear existing data before fetching the first page again.

Loading the same page twice. Without proper guards, tapping the last item multiple times can fire multiple identical requests. Use boolean flags like isLoadingMore to prevent concurrent fetches of the same page.

Ignoring network errors in pagination. If a page-load request fails, don't silently ignore it. Decrement currentPage so the next attempt tries the same page again, and show an error message to the user.

Not testing cancellation. Set network throttling in Xcode (Simulator > Device > Network Link Conditioner) and verify that navigating away actually stops the spinner and doesn't log errors. A crashing or hung app is worse than no app.

Mixing @Published and @State inconsistently. Use @Published in your ViewModel for data shared across the screen and @State in the View for local UI state. Clarity here prevents state synchronization bugs.

Key takeaways

  • .refreshable with async functions provides automatic pull-to-refresh UI; wrap your fetch logic in an async function and attach it to your List or ScrollView.
  • Detect the last item in a list using .onAppear on rows, then trigger a paginated fetch; guard against duplicate requests with a loading flag.
  • Display loading spinners, empty states, and error messages clearly so users always know what's happening.
  • Tasks created with .task are automatically cancelled when the view disappears, stopping network requests and preventing crashes.
  • Reset pagination state on refresh, handle network errors by retrying the same page, and test cancellation behavior on slow networks.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles