Skip to content

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.

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 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.

growth.ts
/** 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 check 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
* checks 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.
*
* Pure and framework free: it decides from plain numbers and never touches
* the DOM or the engine, which is what makes it testable on its own
* (growth.test.ts).
*/
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
}

checkGrowth() runs after mount, after every change event, and again once each page lands, the same moments a framework effect would re-run at. It reads the engine’s own riffle.getSnapshot().activeIndex, registers each new card, and grows the count with riffle.setCount().

infinite-feed.ts
/**
* Checks the growth guard against the current state and, if it says yes,
* starts the simulated fetch. Called after mount, after every `change`,
* and after every append: the three moments either input to the guard
* (the active index, or the card count) can change.
*/
function checkGrowth(): void {
const activeIndex = riffle.getSnapshot().activeIndex
if (!shouldGrow({ activeIndex, count: cards.length, isLoading }, GROWTH_THRESHOLD)) return
isLoading = true
updateLoadingText()
timer = setTimeout(() => {
const page = generateCards(nextId, PAGE_SIZE)
nextId += PAGE_SIZE
page.forEach((card) => stack.appendChild(buildCard(card)))
const startIndex = cards.length
cards = [...cards, ...page]
page.forEach((_, i) =>
riffle.registerNode(startIndex + i, stack.children[startIndex + i] as HTMLElement),
)
riffle.setCount(cards.length)
isLoading = false
timer = null
updateLoadingText()
updateReadout()
checkGrowth()
}, LATENCY_MS)
}

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.