Advanced Lists & Pagination in SwiftUI

Published: (December 18, 2025 at 07:07 PM EST)
3 min read
Source: Dev.to

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 hasMore explicitly

📜 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 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 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 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.

Back to Blog

Related posts

Read more »

Introducing Marlin

!Cover image for Introducing Marlinhttps://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3....