ESC

Type to search the knowledge base.

Infinite Scroll Feed

Machine-coding brief for a cursor-paginated feed — IntersectionObserver, race safety, empty/error states, and a11y.

intermediate5 min read
  • machine-coding
  • react
  • performance
  • async
  • interview

Problem statement

Build an infinite scroll feed: a vertical list of cards that loads the next page when the user approaches the end. Mock or real API with cursor-based pagination. Interviewers care about fetch lifecycle, observer cleanup, duplicate pages, and accessibility — not Instagram-level design.

Requirements

Must have

  • Initial page load on mount
  • Append next page when sentinel enters viewport (or near-bottom scroll)
  • Loading indicator for first page and “load more”
  • End-of-feed state (hasMore === false)
  • Error state with retry
  • No duplicate items when pages overlap or effect re-runs
  • Keyboard users can still reach content (list is in tab order; don’t rely on scroll alone for critical actions)

Should have

  • AbortController on unmount / query change
  • Pull-to-refresh or explicit “Reload”
  • Skeleton placeholders for first paint

Nice to have

  • Virtualization for 1k+ rows
  • Scroll restoration
  • Filter/tab that resets cursor and list

Planning (5 minutes out loud)

  1. Page API — fetchPage(cursor) → { items, nextCursor }
  2. State — items, cursor, status, hasMore
  3. Trigger — IntersectionObserver on a sentinel div
  4. Guards — in-flight lock so we don’t double-fetch
  5. MVP — list + sentinel + append; then errors/skeletons

Architecture

FeedApp
├── FeedFilters?          // optional, resets feed
├── FeedList
│   ├── FeedCard
│   └── LoadSentinel      // observed target
└── hooks/useInfiniteFeed

Data model

type FeedItem = {
  id: string;
  title: string;
  body: string;
  imageUrl?: string;
};

type Page = {
  items: FeedItem[];
  nextCursor: string | null; // null ⇒ no more pages
};

type FeedStatus = "idle" | "loading" | "loadingMore" | "error" | "success";

Component API

type InfiniteFeedProps = {
  fetchPage: (cursor: string | null, signal: AbortSignal) => Promise<Page>;
  pageSize?: number; // documentation only if server-controlled
};

Implementation sketch

Infinite feed hook

function useInfiniteFeed(fetchPage: InfiniteFeedProps["fetchPage"]) {
  const [items, setItems] = useState<FeedItem[]>([]);
  const [cursor, setCursor] = useState<string | null>(null);
  const [hasMore, setHasMore] = useState(true);
  const [status, setStatus] = useState<FeedStatus>("idle");
  const [error, setError] = useState<string | null>(null);
  const inFlight = useRef(false);
  const seen = useRef(new Set<string>());

  const load = useCallback(
    async (reset: boolean) => {
      if (inFlight.current) return;
      if (!reset && !hasMore) return;

      inFlight.current = true;
      setStatus(reset || items.length === 0 ? "loading" : "loadingMore");
      setError(null);

      const controller = new AbortController();
      try {
        const page = await fetchPage(reset ? null : cursor, controller.signal);
        setItems((prev) => {
          const base = reset ? [] : prev;
          if (reset) seen.current.clear();
          const next: FeedItem[] = [];
          for (const item of page.items) {
            if (seen.current.has(item.id)) continue;
            seen.current.add(item.id);
            next.push(item);
          }
          return base.concat(next);
        });
        setCursor(page.nextCursor);
        setHasMore(page.nextCursor != null);
        setStatus("success");
      } catch (e) {
        if (e instanceof DOMException && e.name === "AbortError") return;
        setError(e instanceof Error ? e.message : "Failed to load");
        setStatus("error");
      } finally {
        inFlight.current = false;
      }
    },
    [cursor, fetchPage, hasMore, items.length]
  );

  // initial
  useEffect(() => {
    void load(true);
    // eslint-disable-next-line react-hooks/exhaustive-deps -- mount once for MVP
  }, []);

  return {
    items,
    status,
    error,
    hasMore,
    loadMore: () => void load(false),
    reload: () => void load(true),
  };
}

For production, store the abort controller in a ref and abort on unmount/filter change; keep load dependencies tight to avoid stale closures (or use a reducer).

Sentinel with IntersectionObserver

function LoadSentinel({
  onVisible,
  disabled,
}: {
  onVisible: () => void;
  disabled: boolean;
}) {
  const ref = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (disabled) return;
    const node = ref.current;
    if (!node) return;

    const io = new IntersectionObserver(
      (entries) => {
        if (entries.some((e) => e.isIntersecting)) onVisible();
      },
      { root: null, rootMargin: "200px", threshold: 0 }
    );

    io.observe(node);
    return () => io.disconnect();
  }, [onVisible, disabled]);

  return <div ref={ref} data-testid="feed-sentinel" aria-hidden="true" />;
}
function FeedApp({ fetchPage }: InfiniteFeedProps) {
  const { items, status, error, hasMore, loadMore, reload } =
    useInfiniteFeed(fetchPage);

  return (
    <section aria-label="Feed" aria-busy={status === "loading"}>
      {status === "loading" && items.length === 0 && <p>Loading…</p>}
      {error && (
        <p role="alert">
          {error}{" "}
          <button type="button" onClick={reload}>
            Retry
          </button>
        </p>
      )}
      <ul>
        {items.map((item) => (
          <li key={item.id}>
            <article>
              <h2>{item.title}</h2>
              <p>{item.body}</p>
            </article>
          </li>
        ))}
      </ul>
      {hasMore && (
        <LoadSentinel
          onVisible={loadMore}
          disabled={status === "loadingMore" || status === "loading"}
        />
      )}
      {status === "loadingMore" && <p>Loading more…</p>}
      {!hasMore && items.length > 0 && <p>You’re all caught up.</p>}
      {status === "success" && items.length === 0 && <p>No posts yet.</p>}
    </section>
  );
}

Accessibility notes

  • Semantic list / articles; headings per card if content-heavy.
  • aria-busy on first load; role="alert" for errors.
  • Infinite scroll fails some users — offer a visible “Load more” button as progressive enhancement (strong interview signal).
  • Don’t steal focus when new items append.
  • Images: alt, dimensions to limit CLS (Core Web Vitals).

Performance notes

  • Prefer cursor pagination over page=n (stable under inserts).
  • rootMargin prefetches before the user hits the bottom.
  • Dedupe by id when the backend is eventually consistent.
  • Virtualize when asked (windowing discussion).
  • Avoid putting heavy work in the observer callback; just trigger fetch.

Interview expectations

Signal What good looks like
Pagination Cursor + hasMore, not magic page numbers only
Observer Create + disconnect cleanup
Concurrency In-flight guard; no double page
UX First load, load more, end, error/retry
A11y Load more fallback, alerts, semantics
Time Working list + sentinel in ~30 min

Extensions they may ask live

  1. Switch tabs/filters — reset cursor, abort in-flight
  2. Optimistic remove / like on a card
  3. Virtualized window (@tanstack/react-virtual or DIY)
  4. Offline cache of last page

Get the fetch state machine right; virtualization is a follow-up, not the MVP.