On this page
Tracks

Infinite Scrollhigh-yield

Last reviewed 22 Sept 2026

Problem

Build an infinitely scrolling list. fetchPage(cursor) returns { items, nextCursor } (nextCursor is null on the last page). Load the first page on mount and the next page whenever the user nears the bottom:

<InfiniteList
fetchPage={(cursor) => fetch(`/api/posts?cursor=${cursor ?? ''}`).then((r) => r.json())}
renderItem={(post) => <PostCard post={post} />}
/>

Clarifying questions

  • Page-number or cursor pagination? Cursor — safer when items are inserted while scrolling.
  • Can the same item appear on two pages? Possibly — de-duplicate by id.
  • Error handling? Show a retry button; do not keep firing requests.
  • Very long lists (10,000+ rows)? Follow-up: virtualisation.

Approach

Put an invisible “sentinel” element after the last item and watch it with IntersectionObserver. When it becomes visible, load the next page — unless a request is already in flight, the last request failed, or there are no more pages. All list state lives in a pure reducer, which makes the transitions explicit and testable.

Step-by-step build

Step 1 — the reducer

const initial = { items: [], cursor: null, status: 'idle', hasMore: true, error: null };
function listReducer(state, action) {
switch (action.type) {
case 'start': return { ...state, status: 'loading', error: null };
case 'success': return { ...state, status: 'idle', items: [...state.items, ...action.items], cursor: action.nextCursor, hasMore: action.nextCursor != null };
case 'error': return { ...state, status: 'error', error: action.error };
default: return state;
}
}

Step 2 — de-duplicate by id

const seen = new Set(state.items.map((x) => x.id));
const fresh = action.items.filter((x) => !seen.has(x.id));

Step 3 — load more, guarded

const loadMore = useCallback(async () => {
if (state.status !== 'idle' || !state.hasMore) return; // no double fetches, stop at the end
dispatch({ type: 'start' });
try {
const page = await fetchPage(state.cursor);
dispatch({ type: 'success', ...page });
} catch (error) {
dispatch({ type: 'error', error });
}
}, [state.status, state.hasMore, state.cursor, fetchPage]);

Step 4 — observe the sentinel

useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => entry.isIntersecting && loadMore(), { rootMargin: '300px' });
observer.observe(el);
return () => observer.disconnect();
}, [loadMore]);

rootMargin: '300px' starts loading before the user actually reaches the end.

Final code

import { useCallback, useEffect, useReducer, useRef } from 'react';
export const initialList = { items: [], cursor: null, status: 'idle', hasMore: true, error: null };
export function listReducer(state, action) {
switch (action.type) {
case 'start':
return { ...state, status: 'loading', error: null };
case 'success': {
const seen = new Set(state.items.map((x) => x.id));
const fresh = action.items.filter((x) => !seen.has(x.id));
return {
...state,
status: 'idle',
items: [...state.items, ...fresh],
cursor: action.nextCursor ?? null,
hasMore: action.nextCursor != null,
};
}
case 'error':
return { ...state, status: 'error', error: action.error };
case 'retry':
return { ...state, status: 'idle', error: null };
default:
return state;
}
}
export function canLoadMore(state) {
return state.status === 'idle' && state.hasMore;
}
export function useInfiniteList(fetchPage) {
const [state, dispatch] = useReducer(listReducer, initialList);
const inFlight = useRef(false); // guards against two observer callbacks before a re-render
const loadMore = useCallback(async () => {
if (!canLoadMore(state) || inFlight.current) return;
inFlight.current = true;
dispatch({ type: 'start' });
try {
const page = await fetchPage(state.cursor);
dispatch({ type: 'success', items: page.items, nextCursor: page.nextCursor });
} catch (error) {
dispatch({ type: 'error', error });
} finally {
inFlight.current = false;
}
}, [state, fetchPage]);
return { state, loadMore, retry: () => dispatch({ type: 'retry' }) };
}
export function InfiniteList({ fetchPage, renderItem }) {
const { state, loadMore, retry } = useInfiniteList(fetchPage);
const sentinelRef = useRef(null);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) loadMore();
},
{ rootMargin: '300px' },
);
observer.observe(el);
return () => observer.disconnect();
}, [loadMore]);
return (
<div>
<ul>
{state.items.map((item) => (
<li key={item.id}>{renderItem(item)}</li>
))}
</ul>
{state.status === 'loading' && <p role="status">Loading…</p>}
{state.status === 'error' && (
<p role="alert">
Could not load more. <button onClick={retry}>Retry</button>
</p>
)}
{!state.hasMore && state.items.length > 0 && <p>You have reached the end.</p>}
{!state.hasMore && state.items.length === 0 && <p>Nothing here yet.</p>}
{state.hasMore && state.status !== 'error' && <div ref={sentinelRef} aria-hidden="true" style={{ height: 1 }} />}
</div>
);
}

Edge cases

  • The first page is loaded because the sentinel is visible on mount — no separate “initial load” code.
  • A short first page that does not fill the screen: the sentinel stays visible, but the observer only fires on changes. After each success the effect re-runs (new loadMore), re-observing and triggering the next page.
  • Duplicate items across pages are dropped by id.
  • After an error the sentinel is removed, so it does not retry in a loop; “Retry” puts it back.
  • nextCursor: null ends loading for good.

Follow-ups

  • Virtualisation (react-window): render only visible rows for huge lists.
  • Restore scroll position when the user navigates back.
  • Scroll events vs IntersectionObserver: scroll handlers fire constantly and need throttling; the observer is cheaper and simpler.
  • Bidirectional loading for chat history (load older messages at the top without jumping).

Common mistakes

  • Firing several requests for the same page because the observer fired twice before the state updated.
  • Page numbers with data that shifts, causing skipped or repeated items.
  • Retrying automatically forever after an error.
  • Forgetting observer.disconnect() on unmount.