Skip to content

Pagination

Every list endpoint — feeds, followers/following, reactions, notifications — pages with an opaque keyset cursor, never OFFSET. The cursor encodes a position on (activity_time DESC, activity_id DESC) (or the equivalent ordering column for followers/reactions/notifications), so pages stay correct even as new rows are inserted ahead of where you’re reading — an OFFSET-based page would skip or repeat rows under concurrent writes. Treat the cursor as opaque: don’t parse it, just pass back whatever the previous page returned as next.

const page = await client.feed('user', 'maya').get({ limit: 20 })
page.results // Activity[]
page.next // string | null — opaque cursor, or null when there's no more

Pass the previous page’s next back in as next to get the following page. The same { limit, next } shape works on every paginated method — feed().get(), feed().followers()/.following(), reactions.list(), and notifications.get().

let page = await client.feed('user', 'maya').get({ limit: 20 })
while (page.next) {
page = await client.feed('user', 'maya').get({ limit: 20, next: page.next })
}

See feeds for the full set of feed reads this applies to.

Requires @dropinnodex/react 0.6.0. useInfiniteFeed is useFeed plus the scroll wiring — attach sentinelRef to a trailing element and pages load as it comes into view.

import { useInfiniteFeed } from '@dropinnodex/react'
function Timeline({ uid }: { uid: string }) {
const { activities, sentinelRef, isLoadingInitial, isLoadingMore, error, retry } =
useInfiniteFeed('timeline', uid, { pageSize: 40 })
// Gate the spinner on isLoadingInitial, NOT isLoading — see below.
if (isLoadingInitial && activities.length === 0) return <FullPageLoading />
return (
<>
{activities.map((a) => <Row key={a.id} activity={a} />)}
{error && <button onClick={() => void retry()}>Try again</button>}
<div ref={sentinelRef}>{isLoadingMore && <Spinner />}</div>
</>
)
}

rootMargin (default '600px') controls how far ahead of the viewport the next page starts loading. pageSize is worth raising for desktop, where 20 rows can be a single screen and every scroll costs a round trip.

A button fires once per click. An IntersectionObserver fires on intersect and again on every reflow, which turns three edge cases into the normal path. useFeed handles all three, so a hand-rolled sentinel is fine too — as long as you use the right flags:

  • Gate the list on isLoadingInitial, never on isLoading. isLoading is the union of initial and next-page loading. A list gated on it swaps itself for a spinner while page 2 is in flight, which unmounts the sentinel — scrolling then stalls permanently and the reader loses their position. isLoadingInitial covers only the reads that replace the list (first load, feed switch, refresh()).
  • Bind the sentinel to canLoadMore, not hasNext. canLoadMore also folds in “a page is already in flight” and “the last page failed”. A failed page leaves the cursor unchanged, so retrying on hasNext alone re-issues the identical failed request for as long as the sentinel stays in view. retry() is the deliberate way back in.
  • retry() covers a failed first page too. It re-issues the failed cursor when there is one, and re-reads page 1 when there isn’t — so the same button recovers a feed that never loaded at all, not just one that stopped mid-scroll. Wire it in the empty-error state as well as under the list; that first case is the one users actually hit, on a cold open with bad signal.
  • Duplicates are handled for you. Pages merge deduped by id, so a row inserted ahead of your cursor — or an at-least-once fan-out replay — can’t produce duplicate React keys.

Same hook; FlatList does the observing. sentinelRef is inert where there is no IntersectionObserver, so it costs nothing to leave in shared code.

const { activities, onEndReached, isLoadingMore } = useInfiniteFeed('timeline', uid)
<FlatList
data={activities}
keyExtractor={(a) => a.id}
onEndReached={onEndReached}
onEndReachedThreshold={0.5}
ListFooterComponent={isLoadingMore ? <Spinner /> : null}
/>

Render items instead of activities to interleave promoted activities. The sidecar only arrives with the first page, and later pages keep filling slots from it without extra requests.