Lesson 18 of 30
Cursor pagination's decision-level case is why; this lesson is how — the actual request cycle, the client-side state shape, and the mechanism that actually triggers the next fetch.
13 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
Question 1 of 3
Why do most real infinite-query implementations store loaded data as an array of pages rather than flattening every response into one list the moment it arrives?
Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
An earlier lesson in this app covers why cursor-based pagination beats offset for a dataset that keeps changing while someone's mid-scroll. This lesson picks up where that one leaves off: the actual code shape for an infinite list built on cursor pagination.
Three concrete pieces make up a real implementation:
nextCursor in every response, and the client's job is just to store it and send it back as a parameter on the next request.The request/response cycle is concrete and mechanical: page 1 returns some items plus nextCursor: "abc". Requesting page 2 means sending ?cursor=abc, which returns more items plus either a new nextCursor or null once there's nothing left.
type Page = { items: Item[]; nextCursor: string | null };
function useInfiniteItems() {
const [pages, setPages] = useState<Page[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [isFetching, setIsFetching] = useState(false);
const [isDone, setIsDone] = useState(false);
const fetchNextPage = useCallback(async () => {
if (isFetching || isDone) return;
setIsFetching(true);
const query = cursor ? `?cursor=${cursor}` : "";
const response = await fetch(`/api/items${query}`);
const page: Page = await response.json();
setPages((prev) => [...prev, page]);
setCursor(page.nextCursor);
setIsDone(page.nextCursor === null);
setIsFetching(false);
}, [cursor, isFetching, isDone]);
// Flattening happens here, at render time — the pages array itself
// stays intact, so each page's own cursor is still there if needed.
const items = pages.flatMap((page) => page.items);
return { items, fetchNextPage, isFetching, isDone };
}
Keeping pages as an array-of-pages instead of one growing flat array means each page's own metadata, its cursor, whether it succeeded, is still attached to it individually. items.flatMap(...) (or .flat() on an array of item arrays) turns that into one continuous list purely for rendering, without losing the per-page structure underneath.
Triggering fetchNextPage is the other half of this. An IntersectionObserver watching a sentinel element placed near the bottom of the loaded list is the real, commonly-used mechanism for this — it's more efficient than a scroll event listener recalculating the list's position on every scroll tick, because it only fires when the sentinel actually crosses into view.
function useLoadMoreOnVisible(onVisible: () => void) {
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const node = sentinelRef.current;
if (!node) return;
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) onVisible();
});
observer.observe(node);
return () => observer.disconnect();
}, [onVisible]);
return sentinelRef;
}
Page 1 loads: 3 items render, and the response returns nextCursor: "c1". A sentinel sits just after the last item.
The sentinel itself is just an empty element rendered after the last loaded item — <div ref={sentinelRef} /> — with nothing visible about it. It exists purely as something the observer can watch.
isFetching check, that can trigger the same page's fetch multiple times.nextCursor: null is the actual end-of-list signal, not an empty items array. A page can return null for its cursor while still containing items — that's simply the last page, and the client should stop fetching, not treat it as an error.IntersectionObserver exists specifically to avoid that — it's a browser-native way to be told "this element is now visible" without the app doing that math itself, repeatedly, on every scroll event."How would you implement infinite scroll against a paginated API?" is a common follow-up to the decision-level pagination question, and naming the array-of-pages state shape and IntersectionObserver-driven fetch trigger — not just "I'd use a library for it" — is what shows real implementation experience, not just familiarity with the feature.