Milestone 4: cursor pagination, pull-to-refresh, and optimistic likesswift-6.4/ios-26
Lesson 5 / 7
Unit 25 · Snapgram

Milestone 4: cursor pagination, pull-to-refresh, and optimistic likes

Coming from
Not covered in this lesson — showing TypeScript.
Note

Unit 25 · Project 5, Milestone 4. This milestone makes the feed feel alive: it grows as you scroll, refreshes when you pull, and likes respond instantly. The next lesson is the AI checkpoint on a feed cache built to support exactly this.

Three interactions define a modern feed: it pages, it refreshes, and it responds to taps before the network answers. Each has a correct shape and a naive shape that breaks under real use — a shifting offset, a refresh that fights an in-flight load, a like that lies. This milestone builds the correct shape of all three.

Cursor (keyset) pagination

Reeler used page numbers, which is fine for a static result set. A live feed shifts: a new post at the top pushes everything down, so "page 2 by offset" re-shows or skips rows. The fix is keyset pagination — page by the created_at of the last row you have, not by a count.

protocol FeedRepository: Sendable {
    func page(before cursor: Date?, limit: Int) async throws -> [Post]
}

struct SupabaseFeedRepository: FeedRepository {
    let client: SupabaseClient

    func page(before cursor: Date?, limit: Int) async throws -> [Post] {
        var query = client.from("posts").select().order("created_at", ascending: false)
        if let cursor {
            query = query.lt("created_at", value: cursor.ISO8601Format())
        }
        return try await query.limit(limit).execute().value
    }
}

Each page asks for "the next limit posts older than the last one I have." Inserting a new post at the top never disturbs the cursor, because the cursor is anchored to a real row's timestamp, not to a position. The view model tracks the cursor and appends, with the same in-flight guard as Reeler:

@Observable
@MainActor
final class FeedViewModel {
    private(set) var posts: [Post] = []
    private(set) var isLoadingPage = false
    private var canLoadMore = true

    private let repo: FeedRepository
    init(repo: FeedRepository) { self.repo = repo }

    func loadMoreIfNeeded(currentItem post: Post) async {
        guard post.id == posts.last?.id else { return }
        await loadNextPage()
    }

    private func loadNextPage() async {
        guard canLoadMore, !isLoadingPage else { return }
        isLoadingPage = true
        defer { isLoadingPage = false }
        do {
            let next = try await repo.page(before: posts.last?.createdAt, limit: 20)
            if next.isEmpty { canLoadMore = false; return }
            posts.append(contentsOf: next)
        } catch { canLoadMore = false }
    }
}

Pull-to-refresh

.refreshable gives you the platform pull gesture and, crucially, keeps the spinner up until the async work you give it finishes. Refreshing is "fetch the newest page from scratch" — reset the cursor and replace the list.

List(model.posts) { post in
    PostRow(post: post)
        .task { await model.loadMoreIfNeeded(currentItem: post) }
}
.refreshable { await model.refresh() }
func refresh() async {
    canLoadMore = true
    do {
        let fresh = try await repo.page(before: nil, limit: 20)
        posts = fresh                      // replace, don't append
    } catch { /* keep existing posts, surface a message */ }
}
Tip

.refreshable awaits your closure. The spinner stays visible for exactly as long as the await inside runs — no manual "begin/end refreshing" bookkeeping like UIRefreshControl. Give it the real async work and the UI is correct for free. Don't fire-and-forget a Task inside it, or the spinner vanishes immediately while work continues.

Optimistic likes

A like should feel instant. Waiting for a round trip before the heart fills makes the app feel slow. The optimistic pattern: update local state first, fire the network call, and roll back if it fails.

struct Post: Identifiable, Codable, Sendable, Hashable {
    let id: UUID
    // ... other fields ...
    var likeCount: Int
    var likedByMe: Bool
}

func toggleLike(_ post: Post) async {
    guard let index = posts.firstIndex(where: { $0.id == post.id }) else { return }
    let wasLiked = posts[index].likedByMe

    // 1. Optimistic local update — UI reacts immediately.
    posts[index].likedByMe.toggle()
    posts[index].likeCount += wasLiked ? -1 : 1

    // 2. Reconcile with the server.
    do {
        if wasLiked { try await repo.unlike(post.id) }
        else        { try await repo.like(post.id) }
    } catch {
        // 3. Roll back on failure.
        posts[index].likedByMe = wasLiked
        posts[index].likeCount += wasLiked ? 1 : -1
    }
}

Because Post is a value type in an array the view model owns, toggling posts[index] is a local mutation SwiftUI observes instantly — no waiting on Postgres. The like/unlike repo calls insert or delete a row in likes, authorized by the RLS policies from Milestone 2 (you can only like as yourself). If the call throws, you undo the exact change you made. The user sees a heart that fills immediately and, in the rare failure, quietly un-fills.

Coming from TypeScript

This is the same pattern as an optimistic update in React Query (onMutate sets the cache, onError rolls back, onSettled refetches). Swift has no library ceremony here — you mutate the array, await the call, and reverse the mutation in catch. Value semantics make the "snapshot the old value and restore it" step trivial: wasLiked is the snapshot.

The social surfaces: explore, comments, profile

With the feed's read/write/page patterns in hand, three more screens fall out as different reads of the same tables — no new backend concepts, just new queries and layouts.

Explore

Explore is a broader read of posts — not your feed's recency slice but a discovery set (say, ordered by like count), rendered as a three-column grid instead of a list. Same table, same RLS, different query and presentation:

struct ExploreScreen: View {
    @State private var search = ""
    private let columns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 3)

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: columns, spacing: 2) {
                    ForEach(posts) { post in
                        PostThumbnail(post: post).aspectRatio(1, contentMode: .fill).clipped()
                    }
                }
            }
            .navigationTitle("Explore")
            .searchable(text: $search, prompt: "Search")
        }
    }
}

Comments

Comments are a new table with a foreign key: comments (id, post_id, author_id, body, created_at) under the same read-anyone / write-your-own RLS. The comments query is a filter on post_id; posting one is an insert your with check policy validates as authored by you.

struct SupabaseCommentsRepository: Sendable {
    let client: SupabaseClient

    func comments(for postID: UUID) async throws -> [Comment] {
        try await client.from("comments")
            .select().eq("post_id", value: postID)
            .order("created_at", ascending: true)
            .execute().value
    }

    func add(_ body: String, to postID: UUID) async throws {
        try await client.from("comments")
            .insert(["post_id": postID.uuidString, "body": body])
            .execute()
    }
}

Present them from a post's comment button as a sheet with medium/large detents, a list of rows, and an add-comment bar pinned to the bottom with safeAreaInset(edge: .bottom). The post_id foreign key is the join that makes "a post and its comments" one coherent thing.

Profile

A profile is the profiles row (for the bio and the follower/following counts) plus posts filtered to that author — the feed query with an author_id equality:

func profile(for userID: UUID) async throws -> Profile {
    async let row: ProfileRow = client.from("profiles").select().eq("id", value: userID).single().execute().value
    async let posts: [Post] = client.from("posts").select().eq("author_id", value: userID)
        .order("created_at", ascending: false).execute().value
    return try await Profile(row: row, posts: posts)
}

The async let runs the two reads concurrently and awaits both — the profile header and the grid load together. Render the header (avatar, counts, bio, an action button) above a three-column post grid, same tile as Explore.

Tip

Notice the pattern: one schema under RLS, read four ways. The feed, explore, a profile's posts, and a post's comments are all selects with a different filter or order. The hard parts — auth, RLS, storage, pagination — you did once; the surfaces are cheap once the data layer is right. That's the whole argument for getting the backend spine correct first.

Checkpoint

Your app should now: page the feed by cursor as you scroll (no repeats when new posts exist), pull-to-refresh to load the newest posts with a spinner that waits for the fetch, like/unlike a post with an instant UI response that rolls back if the server rejects it, browse an explore grid, open a post's comments and add one, and view a profile with its counts and post grid.

Stretch: debounce rapid like/unlike taps so a user mashing the heart doesn't send a burst of conflicting requests — coalesce to the final state.

Knowledge check

Q: Why page by created_at cursor instead of a numeric offset for a live feed? A live feed shifts as new posts arrive, so an offset re-shows or skips rows between page loads. A keyset cursor anchored to the last row's timestamp is stable — "older than what I have" is unaffected by insertions at the top.

Q: What are the three steps of an optimistic like, and which one is easy to forget? Update local state immediately, fire the network call, and roll back on failure. The rollback is the step people skip — without it, a failed like leaves the UI showing a like that didn't persist, lying to the user.