Skip to content
fearchitect
UI System-Design Patterns

Infinite Scroll & Feeds

Cursor-paginated feed with a bounded DOM and accessible fallback.

By Abas TurabliReviewed

Summary

A production feed uses keyset/cursor pagination — not offset — so skips and duplicates don't occur as data shifts. An `IntersectionObserver` sentinel triggers the next page load automatically. TanStack Virtual keeps the rendered DOM bounded regardless of list length. A 'load more' button preserves keyboard and screen-reader access.

Jump to the interview angle

An infinite-scroll feed auto-fetches the next page as the user nears the bottom, creating the appearance of a continuous list.

The two foundational choices drive every other decision:

  • Cursor vs offset pagination. Offset pagination (LIMIT n OFFSET k) produces skips and duplicates when rows are inserted while the user scrolls. A keyset cursor (e.g. after: "2xZ9q") encodes the last item's sort key; inserts don't shift it.
  • Observer vs scroll event. A scroll-event listener fires hundreds of times per scroll and lives on the main thread. An IntersectionObserver sentinel fires once, off the critical path, when an invisible marker enters the viewport.
Sentinel visibility triggers a single observer callback; no scroll listener needed.

IntersectionObserver sentinel hook

The hook attaches an observer to a ref, calls onIntersect once when visible, and disconnects on unmount. Wire the ref to an empty <div> after the list.

useIntersectionSentinel — observer hooktsx
import { useEffect, useRef } from "react";

interface Options {
  onIntersect: () => void;
  enabled?: boolean;
  rootMargin?: string;
}

/** Attach to a sentinel <div> at the bottom of the list. */
export function useIntersectionSentinel({
  onIntersect,
  enabled = true,
  rootMargin = "200px",
}: Options) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!enabled || !ref.current) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) onIntersect();
      },
      { rootMargin },
    );

    observer.observe(ref.current);
    return () => observer.disconnect();
  }, [enabled, onIntersect, rootMargin]);

  return ref;
}

// --- Usage with TanStack Query v5 infinite query ---
import { useInfiniteQuery } from "@tanstack/react-query";

interface Post { id: string; body: string; }
interface Page { items: Post[]; nextCursor: string | null; }

async function fetchFeed({ pageParam = null }: { pageParam?: string | null }): Promise<Page> {
  const url = pageParam ? `/api/feed?after=${pageParam}` : "/api/feed";
  const res = await fetch(url);
  if (!res.ok) throw new Error("fetch failed");
  return res.json();
}

export function Feed() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery({
      queryKey: ["feed"],
      queryFn: fetchFeed,
      initialPageParam: null,
      getNextPageParam: (last) => last.nextCursor ?? undefined,
    });

  const sentinelRef = useIntersectionSentinel({
    onIntersect: fetchNextPage,
    enabled: hasNextPage && !isFetchingNextPage,
  });

  const posts = data?.pages.flatMap((p) => p.items) ?? [];

  return (
    <div>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.body}</li>
        ))}
      </ul>

      {/* Sentinel — invisible; triggers next fetch */}
      <div ref={sentinelRef} aria-hidden="true" />

      {/* Accessible fallback for keyboard / SR users */}
      {hasNextPage && (
        <button
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? "Loading…" : "Load more"}
        </button>
      )}
    </div>
  );
}

rootMargin: '200px' pre-fetches before the sentinel is fully visible. The button fallback ensures keyboard and screen-reader users can advance the feed.

Virtualize once the list exceeds ~200 rows

Without windowing, every page appended keeps its DOM nodes alive. At 1,000+ items this causes layout thrash and high memory. Add @tanstack/react-virtual — measure each row, render only the visible window (~30 nodes), and set a spacer div equal to the total estimated height. Scroll restoration: save scrollTop in session storage on popstate and restore after the initial page hydrates.

Interview angle

Interviewers probe why offset pagination breaks at scale, how the sentinel avoids scroll event spam, and what windowing buys. Name the concrete tradeoffs: keyset cursors prevent skip/duplicate on insert, IntersectionObserver is passive, TanStack Virtual caps DOM nodes at ~30.

Soundbite: "Cursor for correctness, sentinel for triggering, virtualizer for DOM health, 'load more' for keyboard users."

Key terms

keyset pagination
Paginate by encoding the last-seen row's sort key as a cursor; immune to inserts shifting offsets.
sentinel element
An empty DOM node at the list bottom; `IntersectionObserver` fires when it enters the viewport.
list virtualization
Render only visible rows plus a small overscan; TanStack Virtual manages row measurement and offsets.
scroll restoration
Saving and re-applying scroll position so back-navigation returns users to their place in the feed.
IntersectionObserver
Browser API that fires a callback when a target element crosses a viewport threshold; no scroll listener needed.

Further reading

Search fearchitect

Jump to a topic, mode, or action.