```html SwiftUI: How to Build Pagination (iOS 17+, 2026)

How to Build Pagination in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: onAppear / .task Updated: May 11, 2026
TL;DR

Attach onAppear to the last item in your list and call an async fetch inside a .task modifier; SwiftUI handles Task cancellation and you get seamless infinite scroll.

List(viewModel.items) { item in
    ItemRow(item: item)
        .onAppear {
            if item == viewModel.items.last {
                Task { await viewModel.loadNextPage() }
            }
        }
}
.task { await viewModel.loadNextPage() }

Full implementation

The pattern uses an @Observable view-model that holds a page counter, a Boolean guard against duplicate in-flight requests, and the accumulated items array. A LazyVStack inside a ScrollView keeps memory lean by only rendering visible rows. The sentinel trick — watching for the last item's onAppear — is the most reliable trigger because it fires exactly when the user is about to run out of content, regardless of row height.

import SwiftUI

// MARK: - Model

struct Post: Identifiable, Equatable {
    let id: Int
    let title: String
}

// MARK: - ViewModel

@Observable
final class FeedViewModel {
    var items: [Post] = []
    var isLoading = false
    var hasMore = true

    private var currentPage = 0
    private let pageSize = 20

    func loadNextPage() async {
        guard !isLoading, hasMore else { return }
        isLoading = true
        defer { isLoading = false }

        // Simulate network fetch — replace with your real API call.
        try? await Task.sleep(for: .milliseconds(600))

        let nextPage = currentPage + 1
        let newPosts = (1...pageSize).map { i in
            Post(id: (nextPage - 1) * pageSize + i,
                 title: "Post \((nextPage - 1) * pageSize + i)")
        }

        // Pretend there are only 3 pages of data.
        if nextPage >= 3 {
            hasMore = false
        }

        items.append(contentsOf: newPosts)
        currentPage = nextPage
    }
}

// MARK: - Views

struct FeedView: View {
    @State private var viewModel = FeedViewModel()

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVStack(spacing: 0) {
                    ForEach(viewModel.items) { item in
                        PostRow(post: item)
                            .onAppear {
                                if item == viewModel.items.last {
                                    Task { await viewModel.loadNextPage() }
                                }
                            }
                        Divider()
                    }

                    if viewModel.isLoading {
                        ProgressView()
                            .padding(.vertical, 24)
                    }

                    if !viewModel.hasMore {
                        Text("You're all caught up 🎉")
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                            .padding(.vertical, 24)
                    }
                }
            }
            .navigationTitle("Feed")
            .task { await viewModel.loadNextPage() }
        }
    }
}

struct PostRow: View {
    let post: Post

    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 4) {
                Text(post.title)
                    .font(.headline)
                Text("Tap to read more")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            Spacer()
            Image(systemName: "chevron.right")
                .foregroundStyle(.tertiary)
        }
        .padding(.horizontal, 16)
        .padding(.vertical, 12)
        .accessibilityElement(children: .combine)
        .accessibilityLabel(post.title)
        .accessibilityHint("Tap to read more")
    }
}

#Preview {
    FeedView()
}

How it works

  1. @Observable view-model. The FeedViewModel class is annotated with @Observable (iOS 17 Observation framework), so SwiftUI only re-renders views that actually read a changed property — no manual objectWillChange boilerplate needed.
  2. Guard against duplicate fetches. guard !isLoading, hasMore else { return } on line 20 ensures a second onAppear trigger while a request is already in-flight is silently dropped, preventing duplicate pages or over-fetching.
  3. Sentinel onAppear on the last item. if item == viewModel.items.last identifies the bottom of the current list. When that row scrolls into view the user is about to see the end of content, making this the ideal moment to prefetch the next page.
  4. .task for initial load. The .task modifier on ScrollView fires when the view appears and its structured-concurrency Task is automatically cancelled if the view disappears — safer than calling Task { } in onAppear for the first load.
  5. End-of-feed state. hasMore = false flips after the last page, which both silences future guard checks and swaps the spinner for a friendly "all caught up" message without any additional state machine needed.

Variants

Prefetch one page ahead (threshold-based)

Rather than waiting for the very last item, trigger loading when the user reaches the n-th item from the end. This removes any perceived loading pause on fast scrollers.

// In PostRow's onAppear closure — replace the last-item check:
.onAppear {
    let prefetchThreshold = 5
    if let index = viewModel.items.firstIndex(of: item),
       index == viewModel.items.count - prefetchThreshold {
        Task { await viewModel.loadNextPage() }
    }
}

Pull-to-refresh alongside pagination

Add .refreshable to the ScrollView and call a reset() method that zeroes currentPage, clears items, sets hasMore = true, then calls loadNextPage() again. SwiftUI will show the native spinner automatically and await the async block before dismissing it.

ScrollView {
    // ... existing content
}
.refreshable {
    viewModel.reset()
    await viewModel.loadNextPage()
}

// In FeedViewModel:
func reset() {
    items = []
    currentPage = 0
    hasMore = true
    isLoading = false
}

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement pagination in SwiftUI for iOS 17+.
Use onAppear to detect the last visible item and .task for async fetching.
Guard against duplicate in-flight requests with an isLoading flag.
Make it accessible (VoiceOver labels on rows, announce new content).
Add a #Preview with realistic sample data (at least 3 pages).

Drop this prompt into Soarias during the Build phase after your screen mockups are locked — it produces wired-up, production-ready pagination you can paste directly into your feature module without further edits.

Related

FAQ

Does this work on iOS 16?

The onAppear / .task pattern works on iOS 15+, but @Observable is iOS 17-only. For iOS 16 targets, swap @Observable final class for class … : ObservableObject and replace the @State private var viewModel with @StateObject private var viewModel. Everything else compiles unchanged.

How do I handle a network error mid-pagination?

Add an error: (any Error)? property to the view-model. Wrap the fetch in a do/catch block and assign caught errors to that property. In the view show a retry button when viewModel.error != nil — tapping it clears the error flag and calls loadNextPage() again. Because currentPage wasn't incremented on failure, the same page will be re-requested automatically.

What is the UIKit equivalent?

In UIKit you implement scrollViewDidScroll(_:) on a UIScrollViewDelegate and compare contentOffset.y + frame.height ≥ contentSize.height - threshold to trigger the next fetch. SwiftUI's onAppear-on-last-item approach is higher-level and avoids manual offset math, but both patterns share the same guard-against-duplicate-requests logic.

Last reviewed: 2026-05-11 by the Soarias team.

```