How to Build Pagination in SwiftUI
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
-
@Observableview-model. TheFeedViewModelclass is annotated with@Observable(iOS 17 Observation framework), so SwiftUI only re-renders views that actually read a changed property — no manualobjectWillChangeboilerplate needed. -
Guard against duplicate fetches.
guard !isLoading, hasMore else { return }on line 20 ensures a secondonAppeartrigger while a request is already in-flight is silently dropped, preventing duplicate pages or over-fetching. -
Sentinel
onAppearon the last item.if item == viewModel.items.lastidentifies 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. -
.taskfor initial load. The.taskmodifier onScrollViewfires when the view appears and its structured-concurrency Task is automatically cancelled if the view disappears — safer than callingTask { }inonAppearfor the first load. -
End-of-feed state.
hasMore = falseflips 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
-
iOS 16 and below:
@Observablerequires iOS 17+. For earlier targets use@StateObject/ObservableObjectand@Publishedinstead. The pagination logic itself is identical. -
Using
Listvs.LazyVStack:Listrecycles rows aggressively and can fireonAppearmultiple times for the same item during reuse. Always guard withisLoadingand an identity check rather than relying on a single fire. -
Forgetting
defer { isLoading = false }: If your fetch throws or you return early, you must still flipisLoadingback.deferguarantees this regardless of the exit path and prevents the list from freezing in a perpetual loading state. -
Accessibility: Screen readers announce list item counts. Consider posting
an
AccessibilityNotification.announcement("Loaded more items") after each successful page append so VoiceOver users know content has been added below.
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.