Milestone 4: cursor pagination, pull-to-refresh, and optimistic likes
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 */ } }
.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.
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.
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.