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
Loadablestate 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'"]
(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 100000makes 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
.tasktrigger and a.refreshablereset, 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