DEV Community

Sebastien Lato
Sebastien Lato

Posted on

Advanced Lists & Pagination in SwiftUI

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
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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) { ... }
Enter fullscreen mode Exit fullscreen mode

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()
    }
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Then retry automatically when back online.


🔁 9. Pull-to-Refresh Without Resetting Everything

.refreshable {
    page = 0
    hasMore = true
    items.removeAll()
    await loadNextPage()
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

No UI needed.
No scrolling simulation.
Pure logic tests.


🧠 Mental Model Cheat Sheet

Ask yourself:

  1. Who owns pagination state?
  2. Can duplicate requests happen?
  3. Is identity stable?
  4. Is the row lightweight?
  5. 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)