This document explains, step by step, how the App component implements an infinite scrolling list using React hooks and the Intersection Observer API.
The component renders a list of text items (Text 1, Text 2, ...) that loads automatically in pages of 10, up to a maximum of 10 pages (100 items total), as the user scrolls down. A loader element at the bottom of the list is watched by an IntersectionObserver; when it becomes visible, the next page is fetched.
const PER_PAGE = 10;
const MAX_PAGES = 10;PER_PAGE— number of items fetched per "page" (batch).MAX_PAGES— total number of pages allowed before scrolling stops loading new data (so the list caps at 100 items).
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const pageRef = useRef(0);
const loadingRef = useRef(false);
const loaderRef = useRef(null);items— the accumulated array of loaded text strings, rendered in the UI.loading— drives the "loading..." text shown to the user.hasMore— whether there are more pages left to fetch; oncefalse, fetching stops permanently.
Refs are used here instead of state because their values need to be read synchronously and immediately inside fetchItems, without waiting for a re-render (state updates are asynchronous and could cause race conditions during fast scrolling).
pageRef— tracks the current page index outside of React's render cycle.loadingRef— acts as a synchronous "lock" to prevent the same fetch from firing multiple times concurrently (e.g., if the observer fires repeatedly beforesetLoading(true)has actually re-rendered).loaderRef— a DOM reference attached to the invisible "sentinel"<div>at the bottom of the list, which theIntersectionObserverwatches.
const fetchItems = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
setLoading(true);
const page = pageRef.current;
const newItems = await new Promise((resolve) => {
setTimeout(
() =>
resolve(
Array.from(
{ length: PER_PAGE },
(_, i) => `Text ${page * PER_PAGE + i + 1}`
)
),
300
);
});
setItems((prev) => [...prev, ...newItems]);
pageRef.current = page + 1;
if (pageRef.current >= MAX_PAGES) {
setHasMore(false);
}
loadingRef.current = false;
setLoading(false);
}, [hasMore]);Step by step:
-
Guard clause:
if (loadingRef.current || !hasMore) return;Prevents fetching if a request is already in progress, or if there's nothing left to load. This is the key mechanism that stops duplicate/overlapping fetches. -
Lock and loading UI:
loadingRef.current = true; setLoading(true);Immediately locks further calls (synchronously, via the ref) and updates the UI state to show "loading...". -
Capture current page:
const page = pageRef.current;Reads the current page number into a local variable so it doesn't shift mid-fetch. -
Simulated network request:
const newItems = await new Promise((resolve) => { setTimeout(() => resolve(...), 300); });
This mimics an API call with a 300ms delay. It generates an array of 10 strings like
Text 1throughText 10, where the numbering is calculated frompage * PER_PAGE + i + 1so each page continues numbering from where the last left off. -
Append results:
setItems((prev) => [...prev, ...newItems]);Adds the newly "fetched" items to the existing list using the functional update form (safe against stale closures). -
Advance the page counter:
pageRef.current = page + 1;Increments the page ref for the next fetch. -
Check for end of data:
if (pageRef.current >= MAX_PAGES) { setHasMore(false); }
Once the max number of pages has been reached,
hasMoreis set tofalse, which will block any future fetch attempts via the guard clause. -
Unlock:
loadingRef.current = false; setLoading(false);Releases the lock and turns off the loading indicator.
Why
useCallback? WrappingfetchItemsinuseCallbackwith[hasMore]as a dependency means a new function is only created whenhasMorechanges — this keeps the function reference stable for theIntersectionObservereffect below, avoiding unnecessary observer re-creation.
useEffect(() => {
fetchItems();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);Runs once when the component first mounts, triggering the first page load immediately (so the user doesn't see an empty screen before scrolling). The empty dependency array [] ensures it only runs on mount; the eslint-disable comment suppresses the warning about fetchItems not being listed as a dependency (intentional, since we only want this on mount).
useEffect(() => {
const node = loaderRef.current;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
fetchItems();
}
},
{
threshold: 0.5,
rootMargin: "300px",
}
);
observer.observe(node);
return () => {
observer.unobserve(node);
observer.disconnect();
};
}, [fetchItems]);This is the core of the infinite-scroll behavior:
- Get the sentinel node:
const node = loaderRef.current;— the DOM element at the bottom of the list (the<div ref={loaderRef}>). - Create the observer:
new IntersectionObserver(callback, options)- The callback fires whenever the sentinel's visibility relative to the viewport changes.
entries[0].isIntersectingchecks if the sentinel is currently visible.- If visible,
fetchItems()is called to load the next page.
- Options:
threshold: 0.5— the callback fires once at least 50% of the sentinel is visible.rootMargin: "300px"— expands the "viewport" boundary by 300px, so loading is triggered before the user actually reaches the very bottom (preloading for a smoother experience).
- Start observing:
observer.observe(node); - Cleanup: When the effect re-runs or the component unmounts, the observer stops watching the node and is disconnected, preventing memory leaks.
This effect re-runs whenever fetchItems changes (i.e., when hasMore changes), ensuring the observer's callback always has access to the latest, non-stale version of fetchItems.
return (
<div className="App">
{items.map((item, idx) => (
<p key={idx} style={{ fontSize: "25px", padding: "1rem" }}>
{item}
</p>
))}
<div ref={loaderRef}>
{loading && "loading..."}
{!hasMore && "No more items"}
</div>
</div>
);- List rendering: Each item in
itemsis rendered as a<p>tag with a generated indexkey. (Note: using array index askeyis acceptable here since items are only ever appended, never reordered or removed.) - Sentinel/status element: The
<div ref={loaderRef}>serves double duty:- It's the element observed by the
IntersectionObserverto trigger more fetches. - It also displays status text:
"loading..."while a fetch is in progress, or"No more items"oncehasMorebecomesfalse.
- It's the element observed by the
| Step | What Happens |
|---|---|
| 1 | Component mounts → fetchItems() runs once to load page 0 |
| 2 | IntersectionObserver starts watching the bottom sentinel <div> |
| 3 | User scrolls down → sentinel enters the viewport (or comes within 300px of it) |
| 4 | Observer callback fires → calls fetchItems() |
| 5 | fetchItems checks the lock (loadingRef) and hasMore, then fetches the next page |
| 6 | New items are appended to items, page counter increments |
| 7 | Repeat steps 3–6 until pageRef.current >= MAX_PAGES |
| 8 | hasMore becomes false → guard clause blocks further fetches → "No more items" is shown |
This pattern — refs for synchronous guards, IntersectionObserver for scroll detection, and useCallback for stable function identity — is a common, performant approach to implementing infinite scroll without relying on scroll event listeners (which are less efficient and harder to debounce correctly).
Infinite Scroll List - Codesandbox