Build Infinite Scroll
Interview Question: "Implement infinite scroll that loads more items as the user scrolls to the bottom."
Requirements
- Load initial page of data
- Detect when user nears the bottom (Intersection Observer)
- Fetch and append next page
- Show loading indicator
- Handle errors with retry
- Prevent duplicate fetches
- Optional: scroll restoration, virtualization for large lists
Core Hook
function useInfiniteScroll<T>({
fetchPage,
pageSize = 20,
}: {
fetchPage: (page: number, signal: AbortSignal) => Promise<T[]>;
pageSize?: number;
}) {
const [items, setItems] = useState<T[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<Error | null>(null);
const loadingRef = useRef(false);
const loadMore = useCallback(async () => {
if (loadingRef.current || !hasMore) return;
loadingRef.current = true;
setLoading(true);
setError(null);
const controller = new AbortController();
try {
const newItems = await fetchPage(page, controller.signal);
setItems(prev => [...prev, ...newItems]);
setHasMore(newItems.length >= pageSize);
setPage(prev => prev + 1);
} catch (err) {
if ((err as Error).name !== 'AbortError') {
setError(err as Error);
}
} finally {
setLoading(false);
loadingRef.current = false;
}
}, [page, hasMore, fetchPage, pageSize]);
return { items, loading, hasMore, error, loadMore };
}Sentinel Element with Intersection Observer
function InfiniteList<T>({
fetchPage,
renderItem,
getKey,
}: {
fetchPage: (page: number, signal: AbortSignal) => Promise<T[]>;
renderItem: (item: T) => ReactNode;
getKey: (item: T) => string;
}) {
const { items, loading, hasMore, error, loadMore } = useInfiniteScroll({ fetchPage });
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
loadMore();
}, []);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) loadMore();
},
{ rootMargin: '200px' }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [loadMore]);
return (
<div className="infinite-list">
{items.map(item => (
<div key={getKey(item)} className="list-item">
{renderItem(item)}
</div>
))}
{loading && <div className="loader">Loading...</div>}
{error && (
<div className="error">
<p>Failed to load. </p>
<button onClick={loadMore}>Retry</button>
</div>
)}
{hasMore && !loading && !error && (
<div ref={sentinelRef} className="sentinel" aria-hidden="true" />
)}
{!hasMore && <div className="end-message">No more items</div>}
</div>
);
}Why Intersection Observer Over Scroll Events
| Approach | Pros | Cons |
|---|---|---|
scroll event | Works everywhere | Fires on every pixel, needs throttle, causes layout thrashing with getBoundingClientRect |
| Intersection Observer | Async, no main-thread blocking, configurable margins | Slightly more setup |
// BAD: scroll-based (causes jank)
window.addEventListener('scroll', throttle(() => {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
if (scrollTop + clientHeight >= scrollHeight - 200) loadMore();
}, 100));
// GOOD: Intersection Observer (zero layout thrashing)
const observer = new IntersectionObserver(callback, { rootMargin: '200px' });
observer.observe(sentinelElement);Virtualization for Large Lists
When the list exceeds ~1000 items, DOM node count becomes a problem. Combine infinite scroll with windowing:
function VirtualizedInfiniteList({ items, loadMore, hasMore, itemHeight, containerHeight }) {
const [scrollTop, setScrollTop] = useState(0);
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(
startIndex + Math.ceil(containerHeight / itemHeight) + 1,
items.length
);
const visibleItems = items.slice(startIndex, endIndex);
return (
<div
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={e => {
const target = e.currentTarget;
setScrollTop(target.scrollTop);
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 200) {
loadMore();
}
}}
>
<div style={{ height: items.length * itemHeight, position: 'relative' }}>
{visibleItems.map((item, i) => (
<div
key={item.id}
style={{
position: 'absolute',
top: (startIndex + i) * itemHeight,
height: itemHeight,
width: '100%',
}}
>
{renderItem(item)}
</div>
))}
</div>
</div>
);
}What Interviewers Look For
- Intersection Observer â Not scroll events, understands
rootMarginfor preloading - Race condition prevention â
loadingRefflag prevents duplicate fetches - Error handling â Retry mechanism, AbortController on unmount
- End-of-list detection â
hasMoreflag based on page size comparison - Virtualization awareness â Mentions windowing for 1000+ item lists, knows
react-window/@tanstack/virtual