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.
First page
Section titled “First page”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 moreimport { useFeed } from '@dropinnodex/react'
// First page loads automatically on mount.const { activities, hasNext, isLoading } = useFeed('user', 'maya')const page = await server.feed('user', 'maya').get({ limit: 20 })Next page via the next cursor
Section titled “Next page via the next cursor”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 })}// The hook tracks the cursor for you — just call loadNext() to append the next page.const { activities, hasNext, loadNext, isLoading } = useFeed('user', 'maya')
return hasNext && <button onClick={loadNext} disabled={isLoading}>Load more</button>let page = await server.feed('user', 'maya').get({ limit: 20 })if (page.next) { page = await server.feed('user', 'maya').get({ limit: 20, next: page.next })}See feeds for the full set of feed reads this applies to.
Infinite scroll
Section titled “Infinite scroll”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.
Three things a button never hits
Section titled “Three things a button never hits”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 onisLoading.isLoadingis 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.isLoadingInitialcovers only the reads that replace the list (first load, feed switch,refresh()). - Bind the sentinel to
canLoadMore, nothasNext.canLoadMorealso folds in “a page is already in flight” and “the last page failed”. A failed page leaves the cursor unchanged, so retrying onhasNextalone 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.
React Native
Section titled “React Native”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}/>Promoted activities
Section titled “Promoted activities”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.
Related
Section titled “Related”- Feeds — the reads this applies to.
- Reactions and Notifications — the other paginated lists.
- Limits — the maximum page size.