Advanced Lists & Pagination in SwiftUI
Source: Dev.to
Lists look simple — until you try to build a real feed.
Then you hit problems like:
- infinite scrolling glitches
- duplicate rows
- pagination triggering too often
- scroll position jumping
- heavy rows killing performance
- async races
- loading states everywhere
- offline + pagination conflicts
This post shows how production SwiftUI apps handle lists and pagination — clean, fast, predictable, and scalable.
🧠 The Core Principle
A real list must:
- load data incrementally
- never block scrolling
- keep identity stable
- avoid duplicate requests
- handle errors gracefully
- support offline data
- remain smooth with large datasets
Everything else builds on this.
🧱 1. The Correct List Architecture
Separate concerns:
View
↓
ViewModel (pagination state)
↓
Service (fetch pages)
The ViewModel owns pagination logic — not the view.
📦 2. Pagination State Model
@Observable
class FeedViewModel {
var items: [Post] = []
var isLoading = false
var hasMore = true
var page = 0
}
This makes pagination:
- predictable
- debuggable
- testable
🔄 3. Fetching Pages Safely
@MainActor
func loadNextPage() async {
guard !isLoading, hasMore else { return }
isLoading = true
defer { isLoading = false }
do {
let response = try await service.fetch(page: page)
items.append(contentsOf: response.items)
hasMore = response.hasMore
page += 1
} catch {
errors.present(map(error))
}
}
Key rules
- guard against duplicate calls
- update state on the main actor
- append, never replace
- track
hasMoreexplicitly
📜 4. Trigger Pagination From the View (Safely)
List {
ForEach(items) { item in
RowView(item: item)
.onAppear {
if item == items.last {
Task { await viewModel.loadNextPage() }
}
}
}
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
}
}
This:
- works with dynamic row heights
- survives rotation
- avoids
GeometryReadertraps
🆔 5. Identity Is Non‑Negotiable
Bad identity kills list performance.
ForEach(items, id: \.id) { ... }
Rules
- IDs must be stable
- Never generate
UUIDs inline - Never use array indices
- Never mutate IDs
Bad identity causes:
- duplicated rows
- animation glitches
- scroll jumps
- state leaking between rows
⚠️ 6. Avoid Heavy Work Inside Rows
Never do this:
RowView(item: item)
.task {
await expensiveWork()
}
Instead
- precompute in the ViewModel
- cache results
- pass lightweight data into rows
Rows should be:
- cheap
- pure
- fast to render
🧊 7. Skeleton & Placeholder Rows
if items.isEmpty && isLoading {
ForEach(0..<5) { _ in
SkeletonRow()
}
}
This:
- preserves layout
- feels faster
- avoids UI jumps
📶 8. Offline + Pagination
When offline:
- load cached pages
- disable pagination
- keep scrolling smooth
Example:
if !network.isOnline {
hasMore = false
}
Then retry automatically when back online.
🔁 9. Pull‑to‑Refresh Without Resetting Everything
.refreshable {
page = 0
hasMore = true
items.removeAll()
await loadNextPage()
}
Avoid:
- rebuilding ViewModels
- resetting identity
- nuking scroll state unnecessarily
⚖️ 10. Performance Rules for Large Lists
- ✅ Use
Listfor large datasets - ✅ Prefer
LazyVStackinsideScrollViewfor custom layouts - ✅ Avoid
GeometryReaderin rows - ✅ Keep rows shallow
- ✅ Cache images aggressively
- ✅ Avoid environment updates per row
- ✅ Avoid nested lists
SwiftUI lists are extremely fast when used correctly.
🧪 11. Testing Pagination Logic
Because logic lives in the ViewModel:
func test_pagination_appends() async {
let vm = FeedViewModel(service: MockService())
await vm.loadNextPage()
await vm.loadNextPage()
XCTAssertEqual(vm.items.count, 40)
}
No UI needed, no scrolling simulation—pure logic tests.
🧠 Mental Model Cheat Sheet
Ask yourself:
- Who owns pagination state?
- Can duplicate requests happen?
- Is identity stable?
- Is the row lightweight?
- Can offline break this?
If all answers are clean → your list will scale.
🚀 Final Thoughts
Advanced lists aren’t about clever tricks. They’re about:
- clear ownership
- stable identity
- predictable pagination
- minimal row work
- clean async handling
Get these right, and your SwiftUI feeds will feel native, fast, and rock‑solid — even with tens of thousands of rows.