Skip to content
Advanced80 min build target7 min guide

Infinite scroll

A feed that loads more content as the user approaches the bottom, without janking scroll position.

Infinite scroll interface reference

HOW TO USE THIS CHALLENGE

  1. 1. Read the briefClarify decisions before coding.
  2. 2. Build from memoryUse the 80-minute target.
  3. 3. Study the guideCompare architecture, tests, and trade-offs.

REQUIREMENTS

  • Load the next page of items automatically when the user scrolls near the end of the list.
  • Show a loading indicator for in-flight fetches and a clear end-of-content state.
  • Prevent duplicate fetches from firing while one is already in flight.
  • Support pull-to-retry (or a retry button) if a page fetch fails.
  • Keep scroll position stable when new items are appended (no jump).

EDGE CASES

  • Fast scrolling past the trigger point multiple times before a fetch resolves must not queue multiple redundant requests.
  • An error on page 2 shouldn't discard the already-loaded page 1 content.
  • Very fast networks can resolve so quickly that the loading indicator flashes — consider a minimum visible duration or skip it below a threshold.
  • Resizing the window or rotating orientation shouldn't re-trigger a duplicate initial fetch.

ACCESSIBILITY

  • Announce 'Loading more items' and 'End of results' via a polite live region so screen reader users get feedback the infinite trigger gives sighted users implicitly.
  • Ensure the load trigger doesn't rely solely on a mouse-only scroll gesture — keyboard users paging with Page Down/End must still be able to reach and trigger it.
  • Provide a manual 'Load more' button as a fallback/alternative to the automatic sentinel for predictability.

SUGGESTED APPROACH

  • Use an IntersectionObserver watching a sentinel element near the bottom of the list, rather than listening to scroll events directly, for performance and simplicity.
  • Guard the fetch with an isLoading flag checked before triggering, and track the current page/cursor in state.
  • Append new items immutably to the existing array; don't replace the whole list, which would reset scroll.
  • For truly large lists, combine with virtualization so DOM node count doesn't grow unbounded as pages accumulate.

EVALUATION RUBRIC

  • No duplicate or lost fetches under fast scrolling.
  • Scroll position remains stable when new items append.
  • Clear, distinct loading, error, and end-of-content states.
  • Trigger mechanism (IntersectionObserver, not raw scroll listeners) is implemented correctly and cleaned up on unmount.

01Understand the product before coding

Learning goals

  • Load the next page of items automatically when the user scrolls near the end of the list.
  • Show a loading indicator for in-flight fetches and a clear end-of-content state.
  • Prevent duplicate fetches from firing while one is already in flight.
  • Support pull-to-retry (or a retry button) if a page fetch fails.

Decisions to state aloud

  • Fast scrolling past the trigger point multiple times before a fetch resolves must not queue multiple redundant requests.
  • An error on page 2 shouldn't discard the already-loaded page 1 content.
  • Very fast networks can resolve so quickly that the loading indicator flashes — consider a minimum visible duration or skip it below a threshold.
  • Resizing the window or rotating orientation shouldn't re-trigger a duplicate initial fetch.

02State model and invariants

Store pages by cursor and keep already loaded data through later failures. Request status identifies the cursor in flight, and hasNext comes from the server. Observer intersection is a trigger, never the source of truth.

TypeScript
type FeedState<Item> = {  pages: { cursor: string | null; items: Item[]; nextCursor: string | null }[];  status: 'idle' | 'loading' | 'error';  requestedCursor: string | null; error?: string;};const items = state.pages.flatMap((page) => page.items);

03Component architecture

  1. 01Feed owns pages and cursor request state.
  2. 02FeedItems renders stable item ids.
  3. 03LoadSentinel owns one IntersectionObserver.
  4. 04LoadMoreButton is the accessible manual equivalent.
  5. 05FeedStatus announces loading, error, and end states.

04Reference implementation walkthrough

Step 1

Observe a sentinel

Create one observer with an early rootMargin, observe the current sentinel, and disconnect on cleanup. Its callback calls a guarded request command.

TSX
useEffect(() => {  const node = sentinelRef.current; if (!node) return;  const observer = new IntersectionObserver(([entry]) => {    if (entry.isIntersecting) loadNext();  }, { rootMargin: '400px 0px' });  observer.observe(node);  return () => observer.disconnect();}, [loadNext]);

Step 2

Deduplicate by cursor

The request command checks status, next cursor, and the last requested cursor. Abort on route teardown and ignore any response that no longer matches the requested cursor.

TypeScript
if (state.status === 'loading' || nextCursor === null) return;if (state.requestedCursor === nextCursor) return;dispatch({ type: 'requested', cursor: nextCursor });const page = await fetchPage(nextCursor, signal);dispatch({ type: 'received', cursor: nextCursor, page });

Step 3

Preserve content across failure

Append a successful page once by cursor. A later failure leaves previous pages visible and exposes Retry for that cursor. End state appears only from an authoritative null cursor.

Step 4

Add restoration and virtualization

Persist cursor/page state for back navigation when required. For long sessions, window DOM rows while retaining stable keys, measured sizes, focus behavior, and an accessible Load more path.

SOLComplete solution, explained simply

Build it yourself first. This is one correct implementation, not the only one — read it top to bottom, then compare the shape of your version.

We are building a feed that loads the next page automatically as you scroll near the bottom, keeps what is already loaded even if a later page fails, never fires two requests for the same page, and still works with the keyboard through a real Load more button. The one big idea: the IntersectionObserver is only a trigger. The truth about what page to load next comes from the server's cursor, and every request is guarded against duplicates.

Feed.tsx
import { useCallback, useEffect, useReducer, useRef } from "react";
type Page<T> = { items: T[]; nextCursor: string | null };type State<T> = {  pages: Page<T>[];  status: "idle" | "loading" | "error";  requestedCursor: string | null;};type Action<T> =  | { type: "requested"; cursor: string | null }  | { type: "received"; cursor: string | null; page: Page<T> }  | { type: "failed" };
function reducer<T>(state: State<T>, action: Action<T>): State<T> {  switch (action.type) {    case "requested":      return { ...state, status: "loading", requestedCursor: action.cursor };    case "received":      if (action.cursor !== state.requestedCursor) return state; // stale response, drop it      return { ...state, status: "idle", pages: [...state.pages, action.page] };    case "failed":      return { ...state, status: "error", requestedCursor: null }; // keep existing pages  }}
export function Feed<T extends { id: string }>({  fetchPage,  renderItem,}: {  fetchPage: (cursor: string | null, signal: AbortSignal) => Promise<Page<T>>;  renderItem: (item: T) => React.ReactNode;}) {  const [state, dispatch] = useReducer(reducer<T>, {    pages: [], status: "idle", requestedCursor: null,  });  const sentinelRef = useRef<HTMLDivElement>(null);  const controllerRef = useRef<AbortController | null>(null);
  const started = state.pages.length > 0;  const nextCursor = started ? state.pages[state.pages.length - 1].nextCursor : null;  const done = started && nextCursor === null;
  const loadNext = useCallback(() => {    if (state.status === "loading") return;                 // one at a time    if (started && nextCursor === null) return;              // nothing left    if (started && state.requestedCursor === nextCursor) return; // same page already asked
    controllerRef.current?.abort();    const controller = new AbortController();    controllerRef.current = controller;    dispatch({ type: "requested", cursor: nextCursor });    fetchPage(nextCursor, controller.signal)      .then((page) => dispatch({ type: "received", cursor: nextCursor, page }))      .catch((error) => { if (error.name !== "AbortError") dispatch({ type: "failed" }); });  }, [state.status, state.requestedCursor, started, nextCursor, fetchPage]);
  // First page on mount.  useEffect(() => {    if (!started && state.status === "idle") loadNext();  }, [started, state.status, loadNext]);
  // Cancel any in-flight request when the component goes away.  useEffect(() => () => controllerRef.current?.abort(), []);
  // Watch the sentinel; load the next page when it comes near the viewport.  useEffect(() => {    const node = sentinelRef.current;    if (!node || done) return;    const observer = new IntersectionObserver(      ([entry]) => { if (entry.isIntersecting) loadNext(); },      { rootMargin: "400px 0px" }, // start early, before the user hits the end    );    observer.observe(node);    return () => observer.disconnect();  }, [loadNext, done]);
  const items = state.pages.flatMap((page) => page.items);
  return (    <div>      <ul>{items.map((item) => <li key={item.id}>{renderItem(item)}</li>)}</ul>
      {!done && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
      <p role="status" aria-live="polite">        {state.status === "loading" ? "Loading more" : done ? "You are all caught up" : ""}      </p>
      {state.status === "error" && (        <button type="button" onClick={loadNext}>Something went wrong \u2014 retry</button>      )}
      {!done && state.status !== "loading" && state.status !== "error" && (        <button type="button" onClick={loadNext}>Load more</button>      )}    </div>  );}

How each part works

Pages are stored as an array of pages

Each entry is one server response: its items and the nextCursor to fetch after it. The flat list of items shown on screen is state.pages.flatMap(p => p.items). Keeping pages separate (not one merged array) makes it easy to know what has loaded and to keep old pages when a new one fails.

The cursor is the source of truth, not the scroll position

nextCursor is read from the last loaded page. done is 'we have started and the last page said nextCursor is null'. The observer never decides what to load — it only calls loadNext, which figures out the cursor itself.

Three guards stop duplicate requests

loadNext returns early if a request is already in flight, if there is nothing left to load, or if the cursor it is about to request is the exact one it last requested. Without these, a fast scroll fires the observer several times and you get the same page appended two or three times.

Stale responses are dropped in the reducer

The 'received' action carries the cursor it was for. If that does not match the cursor we are currently waiting on (because something changed in between), the reducer returns the state unchanged instead of appending a page out of order.

A failed page keeps everything above it

The 'failed' action only sets status to 'error'; it never touches pages. So page one stays visible while page two shows a retry button. It also clears requestedCursor so the retry guard does not block the retry.

The sentinel is a tiny invisible div

A 1px aria-hidden div sits below the list. IntersectionObserver with rootMargin '400px 0px' fires loadNext when that div is still 400px below the viewport, so the next page is usually loaded before the user actually reaches the bottom.

A real Load more button is always available

Below the list there is an actual button that also calls loadNext. Keyboard users, screen-reader users, and anyone where the observer misbehaves get a predictable way to continue, and the error state turns into a retry button.

Why this is correct

  • IntersectionObserver is only a trigger; the next page to load is derived from the server's cursor, never from scroll math.
  • Guard every load against 'already loading', 'nothing left', and 'same cursor as last time' or fast scrolling appends duplicate pages.
  • Tag responses with the cursor they were for and ignore any that no longer match what you are waiting on.
  • A failed page must leave earlier pages untouched and expose a retry for just that page.
  • Always ship a real Load more button as a keyboard and assistive-technology equivalent and as the retry affordance.

05Testing strategy

Critical behavior

  • No duplicate or lost fetches under fast scrolling.
  • Scroll position remains stable when new items append.
  • Clear, distinct loading, error, and end-of-content states.
  • Trigger mechanism (IntersectionObserver, not raw scroll listeners) is implemented correctly and cleaned up on unmount.

Failure and boundary cases

  • Fast scrolling past the trigger point multiple times before a fetch resolves must not queue multiple redundant requests.
  • An error on page 2 shouldn't discard the already-loaded page 1 content.
  • Very fast networks can resolve so quickly that the loading indicator flashes — consider a minimum visible duration or skip it below a threshold.
  • Resizing the window or rotating orientation shouldn't re-trigger a duplicate initial fetch.

Accessibility

  • Announce 'Loading more items' and 'End of results' via a polite live region so screen reader users get feedback the infinite trigger gives sighted users implicitly.
  • Ensure the load trigger doesn't rely solely on a mouse-only scroll gesture — keyboard users paging with Page Down/End must still be able to reach and trigger it.
  • Provide a manual 'Load more' button as a fallback/alternative to the automatic sentinel for predictability.

06Performance and production hardening

  • Use IntersectionObserver instead of continuous scroll calculations.
  • Deduplicate items and pages by stable ids and cursors.
  • Virtualize when DOM count—not network—is measured as the bottleneck.
  • Reserve media dimensions so appended content does not shift.

QAInterview questions and model answers

Answer aloud first. Then open the model answer and compare state ownership, failure handling, accessibility, and trade-offs—not exact wording.

Model answer

It asynchronously reports threshold crossings without application code calculating scroll geometry on every scroll event.

Primary references

Ready to build it?

Implement the brief above in your own environment against a timer close to 80 minutes, then self-review against the rubric before moving on.

Back to all briefs →