Chapter 8 — Pagination & infinite scroll

How the API paginates

{ "items": [20 posts… ],
  "metadata": { "page": 1, "per": 20, "total": 137 } }
  • page / per — which page this is and how many per page (you asked for them: /feed?page=1&per=20).
  • total — the total number of items across all pages. This is the key: **you have more to load as

The paginating view model

// Chirp/Features/Feed/FeedViewModel.swift
@MainActor @Observable
final class FeedViewModel {
    private(set) var posts: [Post] = []
    private(set) var firstPageState: Loadable<Void> = .idle   // controls the big spinner / error
    private(set) var isLoadingMore = false                    // controls the footer spinner
    private(set) var loadMoreFailed = false

    private var page = 0
    private var total = 0
    private let client: APIClient
    init(client: APIClient) { self.client = client }

    var hasMore: Bool { posts.count < total }

    // First load (and pull-to-refresh): reset and fetch page 1.
    func loadFirstPage() async {
        firstPageState = .loading
        page = 0; total = 0
        do {
            let result = try await client.send(API.feed(page: 1))
            posts = result.items
            total = result.metadata.total
            page = 1
            firstPageState = .loaded(())
        } catch is CancellationError {
        } catch { firstPageState = .failed(error) }
    }

    // Called as rows appear; loads the next page when we're near the end.
    func loadNextPageIfNeeded(currentItem: Post) async {
        guard hasMore, !isLoadingMore else { return }
        // Trigger a few items BEFORE the very last, so the next page is ready before the user hits bottom.
        let thresholdIndex = posts.index(posts.endIndex, offsetBy: -5, limitedBy: posts.startIndex)
            ?? posts.startIndex
        guard let currentIndex = posts.firstIndex(where: { $0.id == currentItem.id }),
              currentIndex >= thresholdIndex else { return }

        isLoadingMore = true; loadMoreFailed = false
        defer { isLoadingMore = false }
        do {
            let next = page + 1
            let result = try await client.send(API.feed(page: next))
            // Deduplicate: the feed can shift between requests, so skip ids we already have.
            let existingIDs = Set(posts.map(\.id))
            posts.append(contentsOf: result.items.filter { !existingIDs.contains($0.id) })
            total = result.metadata.total
            page = next
        } catch {
            loadMoreFailed = true      // keep the posts we have; show a "retry" footer
        }
    }

    func refresh() async { await loadFirstPage() }
}
  • Two loading states. The first page uses the full Loadable state machine (Chapter 4) for the big
  • Prefetch before the bottom. We trigger the next page when the user reaches ~5 items before the
  • Deduplicate by id. Between requesting page 1 and page 2, someone might post, shifting everything
  • Failure keeps the list. If loading more fails, we don't blow away what's on screen — we set

Wiring infinite scroll in SwiftUI

// Chirp/Features/Feed/FeedView.swift
struct FeedView: View {
    @State private var viewModel: FeedViewModel

    var body: some View {
        Group {
            switch viewModel.firstPageState {
            case .idle, .loading:
                ProgressView("Loading feed…")
            case .failed(let error):
                ErrorStateView(error: error) { await viewModel.loadFirstPage() }
            case .loaded:
                List {
                    ForEach(viewModel.posts) { post in
                        PostRow(post: post)
                            .task { await viewModel.loadNextPageIfNeeded(currentItem: post) }
                    }
                    footer
                }
            }
        }
        .task { await viewModel.loadFirstPage() }
        .refreshable { await viewModel.refresh() }        // pull-to-refresh resets to page 1
    }

    @ViewBuilder private var footer: some View {
        if viewModel.isLoadingMore {
            HStack { Spacer(); ProgressView(); Spacer() }
        } else if viewModel.loadMoreFailed {
            Button("Tap to retry") { Task { await viewModel.loadNextPageIfNeeded(currentItem: viewModel.posts.last!) } }
        } else if !viewModel.hasMore && !viewModel.posts.isEmpty {
            Text("You're all caught up").foregroundStyle(.secondary).frame(maxWidth: .infinity)
        }
    }
}
flowchart TB Scroll["user scrolls near bottom
(row N-5 appears)"] --> Check{"hasMore && !isLoadingMore?"} Check -->|"no"| Nothing["do nothing"] Check -->|"yes"| Load["fetch page+1"] Load -->|"success"| Append["dedup + append, page++"] Load -->|"failure"| Footer["loadMoreFailed → retry footer"] Append --> Scroll Append -->|"count == total"| End["'You're all caught up'"]

Making it reusable

@MainActor @Observable
final class Paginator<Item: Identifiable> {
    private(set) var items: [Item] = []
    private(set) var isLoadingMore = false
    private var page = 0
    private var total = 0
    var hasMore: Bool { items.count < total }

    // The caller supplies how to fetch a page; the Paginator handles accumulation.
    let fetchPage: (_ page: Int) async throws -> Page<Item>

    init(fetchPage: @escaping (Int) async throws -> Page<Item>) { self.fetchPage = fetchPage }

    func reset() async throws {
        page = 0; total = 0; items = []
        let first = try await fetchPage(1)
        items = first.items; total = first.metadata.total; page = 1
    }
    func loadMore() async throws {
        guard hasMore, !isLoadingMore else { return }
        isLoadingMore = true; defer { isLoadingMore = false }
        let next = try await fetchPage(page + 1)
        let existing = Set(items.map(\.id))
        items.append(contentsOf: next.items.filter { !existing.contains($0.id) })
        total = next.metadata.total; page += 1
    }
}

// Usage: let feed = Paginator { page in try await client.send(API.feed(page: page)) }

Offset vs cursor pagination

  • Drift. If items are inserted or deleted between requests, page boundaries shift — you can see the
  • Deep pages are slow. OFFSET 100000 makes the database count past 100,000 rows every time.

What we built

  • Built infinite scroll on offset pagination: a view model that accumulates pages, tracks
  • Separated first-page state (full spinner/error) from loading-more state (footer), prefetched
  • Wired SwiftUI infinite scroll with a per-row .task trigger and a .refreshable reset, plus an
  • Extracted a reusable Paginator, and contrasted offset vs cursor pagination and when to

Mental model to take away

  • Paginate a long list by accumulating pages and loading the next when the user nears the bottom
  • Keep first-page and load-more states separate; dedup by id, and don't discard loaded
  • Trigger loads with a per-row .task; reset with .refreshable; show an honest footer
  • Offset paging is simple but drifts and slows on deep pages; cursor paging anchors to a stable