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
Define explicit pagination state:
@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 hasMore explicitly
📜 4. Trigger Pagination From the View (Safely)
The correct trigger point is row appearance, not scroll offset math.
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 GeometryReader traps
🆔 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 ViewModel
- cache results
- pass lightweight data into rows
Rows should be:
- cheap
- pure
- fast to render
🧊 7. Skeleton & Placeholder Rows
Instead of blocking spinners:
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 List for large datasets
✔ Prefer LazyVStack inside ScrollView for custom layouts
✔ Avoid GeometryReader in 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.
Top comments (0)