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)