Skip to content

UseFeedOptions

Loads a feed’s first page and keeps it fresh, with optimistic loadNext/addActivity/ refresh helpers.

Returns { activities, loadNext, hasNext, isLoading, error, addActivity, refresh, newCount, showNew, checkNew }, plus the infinite-scroll set: canLoadMore, retry, isLoadingInitial, isLoadingMore.

INFINITE SCROLL. Driving loadNext from an IntersectionObserver is not the same problem as driving it from a button — the observer fires on intersect and again on every reflow, so what a button reaches once, a sentinel reaches constantly:

  • Bind the sentinel to canLoadMore, not hasNext. It also folds in “a page is already in flight” and “the last page failed and nobody has acknowledged it”.
  • Gate a full-page spinner on isLoadingInitial, never on isLoading. The shared flag is true during loadNext too, so a list gated on it unmounts its own sentinel mid-fetch and scrolling stops for good.
  • loadNext is internally guarded anyway: a call while a page is in flight, or while error is set, is a no-op rather than a duplicate request on the same cursor. Pages merge deduped by id, so an overlapping page cannot produce duplicate keys.
  • retry() is the only way past the error guard. Wire it to a button.

opts.initialData

Server-prefetched page for SSR/SSG hydration, e.g. { initialData: await server.feed(group, id).get() } (the shape is @dropinnodex/client’s Page<Activity<TCustom>>, so a server-side feed read passes through verbatim). When present, the hook renders that data on the very first render with isLoading: false — no loading flash — while the mount effect still fires in the background to revalidate against the live feed. A warm provider cache (a prior fetch this session) always wins over initialData, since it is fresher.

opts.pollInterval (deprecated — prefer live) is milliseconds between automatic background checkNew() calls (the Twitter/IG “N new posts ↑” pattern). Omit or pass 0/negative to disable — nothing polls unless this is set. New activities are never auto-prepended: they land in a buffer (newCount) until the app calls showNew(), so an open feed never jumps under the reader. The timer is cleared on unmount and reset whenever pollInterval changes.

checkNew() fetches page 1 and buffers (does not prepend) any activity newer than what’s currently shown, deduped by id against both the shown list and anything already buffered — so a round-tripped addActivity (write, then seen again on the next poll) is never counted as “new”. newCount is the buffered count; call showNew() to prepend the buffer into activities and clear it. Both are headless — this hook does not render the “N new” pill, the app does. The one carve-out from “buffers, never prepends”: calling checkNew() on a feed that is currently EMPTY loads the result straight into activities (there is nothing to jump over), so newCount stays 0 in that case. Caveat: page 1 is fetched at a fixed size of 20, so newCount saturates at 20 (it cannot tell 20 new activities from 200+ new activities) — apps displaying the count should render it as “20+” (or similar) whenever newCount reaches that page size. checkNew() is best-effort when driven by pollInterval’s timer: a failed poll is swallowed and never touches isLoading/error — a transient background failure must not blank an otherwise-working feed.

Inside a disabled provider (enabled={false}) the hook is inert: empty activities, isLoading: false, error: null, enabled: false, all functions no-op resolving undefined, zero network — initialData and polling are ignored too.

Type Parameter Default type
TCustom Record<string, unknown>
optional initialData?: FeedPage<TCustom> | Page<Activity<TCustom>>;

optional live?: boolean;

Keep this feed fresh. Two cadences, both paused while the tab is hidden and both firing immediately on return:

  • every 5s, a head check — one Redis read that answers “did a new activity arrive”, costing a full page read only when it did;
  • every liveRevalidateInterval, the changes a head can never report: edits to the activities on screen, and re-reads of the objects they point at.

optional liveObjectsInterval?: number;

optional liveObjectsMaxRefs?: number;

Ceiling on how many refs one object sweep reads, across all its requests. Default 200; every 100 refs is one request per tick.

A deeply scrolled feed can hold thousands of refs, and refreshing all of them every interval would cost more than the per-card polling this replaces. Past the ceiling the NEWEST activities are refreshed and the tail keeps its last-read values — stale rather than expensive, warned once in the console rather than silently.


optional liveRevalidateInterval?: number;

Milliseconds between revalidation ticks under live. Default 30000; 0 disables revalidation entirely, leaving only the 5s new-activity check.

Deliberately slower than the head cadence. A head check is one Redis read that usually answers “nothing new”; a revalidation is a real page read plus a batch object read, and edits and object updates happen a handful of times a day, not a handful of times a minute. Running both at 5s would bill six times the requests for the same information.


optional onPromotedClick?: (promoted) => void;

Invoked by the returned trackPromotedClick.

Parameter Type
promoted PromotedActivity<TCustom>

void


optional onPromotedImpression?: (promoted, ctx) => void;

Fired once per PLACED SLOT — when a promoted row enters items, not when it enters the viewport (which we cannot see from here). Wire an IntersectionObserver yourself if you need true viewability; this is the hook for sending an event to your own analytics.

Parameter Type
promoted PromotedActivity<TCustom>
ctx { slot: number; }
ctx.slot number

void


optional pageSize?: number;

Rows per request, for every read this hook makes (first page, loadNext, refresh, checkNew). Default 20. Raise it for infinite scroll on a desktop viewport, where 20 rows can be a single screen and each scroll costs a round trip. newCount saturates at this value — see checkNew below.


optional pollInterval?: number;

optional promotedPosition?: number;

Activities before the first promoted slot in items. Default 3.


optional promotedRepeatEvery?: number | null;

Activities between promoted slots. Omit (or null) to place exactly once.