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:
@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:
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:
@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:
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:
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:
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:
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:
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
.refreshablewith 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
.onAppearon 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
.taskare 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.






Comments
Loading comments…