Infinite feed
Riffle has no built-in concept of pagination: count is just how many cards are currently
registered. An infinite feed is count growing while the stack stays live, driven from outside the
engine the same way any other state change would be.
Card 1 of 8
Drag toward the end, or press the arrow keys: once the active card is within two of the last one, a simulated fetch starts, “Loading more” shows, and eight freshly generated cards land a moment later.
The growth rule, isolated
Section titled “The growth rule, isolated”The actual decision of “should we fetch another page yet” is a plain function of three numbers, with no framework import and no timers. It has its own unit tests for exactly that reason: a rule this easy to get subtly wrong (fire once, not on every render; not while already loading; not past the end) is worth testing in isolation from whatever calls it.
/** The state {@link shouldGrow} decides from. */export interface GrowthState { activeIndex: number count: number isLoading: boolean}
/** * True when the feed should fetch and append another page of cards. * * The rule is: the active card is within `threshold` cards of the end * (`count - 1`), and nothing is already loading. `isLoading` is the guard * against firing repeatedly while a page is in flight. A real fetch takes * time (this example simulates 300ms of latency), and `activeIndex` stays * inside the threshold window for that whole window: every render that * happens during it, including ones triggered by state changes unrelated to * navigation, re-evaluates this function. Without the `isLoading` check, a * single approach to the end would request a new page on every one of those * renders instead of exactly once. The caller is expected to hold * `isLoading` true from the moment it starts a fetch until that fetch's * cards are appended. */export function shouldGrow(state: GrowthState, threshold: number): boolean { if (state.isLoading) return false if (state.count <= 0) return false return state.activeIndex >= state.count - 1 - threshold}Wiring it to the stack
Section titled “Wiring it to the stack”An effect with [activeIndex, cards.length] as its dependency array calls shouldGrow on every
render those two values change, and appends a page to React state when it says yes. The effect reads
isLoadingRef, not the isLoading state value: putting isLoading in the dependency array would
make the effect’s own setIsLoading(true) re-run itself before the fetch lands, cancelling nothing
in particular but firing the check twice for one approach to the end.
useEffect(() => { if ( !shouldGrow( { activeIndex, count: cards.length, isLoading: isLoadingRef.current }, GROWTH_THRESHOLD, ) ) { return } isLoadingRef.current = true setIsLoading(true) timerRef.current = setTimeout(() => { setCards((prev) => [...prev, ...generateCards(nextId.current, PAGE_SIZE)]) nextId.current += PAGE_SIZE isLoadingRef.current = false timerRef.current = null setIsLoading(false) }, LATENCY_MS) // No cleanup here: this effect reruns on every activeIndex or // cards.length change, including ones that happen while a page is // already loading, and isLoadingRef guards those reruns into no-ops. // Cancelling the pending timer on every rerun would cancel a fetch the // user is still approaching the end during, not just a stale one.}, [activeIndex, cards.length])Every card carries a stable id (getKey / get-key), so growth never disturbs a card already on
screen, and bounds: 'clamp' makes “the end” a real, fixed thing to approach rather than a loop that
never runs out.
Next: Clamp with controls covers the other end of the count question, a stack that does not wrap.