Infinite Loading with useEffect: Complete Guide (2026)
Infinite scroll feels simple—fetch more data when user reaches the bottom. Then you implement it and discover race conditions, duplicate requests, memory leaks, and janky scrolling. Users scroll too fast, your app makes 10 simultaneous requests, and the whole thing crashes.
This guide shows you how to build production-ready infinite scroll using useEffect and IntersectionObserver. You'll learn to prevent race conditions, handle errors gracefully, optimize memory usage, and create smooth, performant infinite loading that works on slow connections and fast fingers.
Table of Contents
- Why Infinite Loading?
- The Problem with Naive Implementation
- IntersectionObserver: The Right Approach
- Basic Implementation with useEffect
- Preventing Race Conditions
- Loading State Management
- Error Handling and Retry Logic
- Memory Optimization Techniques
- Scroll Position Restoration
- Complete Custom Hook: useInfiniteScroll
- Practical Example: Twitter-Style Feed
- FAQ
Why Infinite Loading?
Traditional pagination breaks the flow. Users click "Next", wait for page reload, lose their scroll position, then hunt for where they were. Infinite scroll solves this by loading content seamlessly as users scroll.
When to use infinite scroll:
- Social media feeds (Twitter, Instagram, TikTok)
- Image galleries and photo streams
- News feeds and article lists
- Product catalogs in mobile apps
- Chat message history
- Search results (with caveats)
When NOT to use infinite scroll:
- E-commerce with footer content (users can't reach footer)
- Long-form content with specific destinations
- Data tables where users need to jump to specific pages
- Content where users need to track position
Comparison with traditional pagination:
| Aspect | Infinite Scroll | Traditional Pagination |
|---|---|---|
| User Experience | Seamless, addictive | Deliberate, controlled |
| Mobile | Excellent | Requires precise taps |
| SEO | Poor (content not crawlable) | Good (each page indexed) |
| Performance | Can degrade with many items | Consistent |
| Accessibility | Harder to implement | Native keyboard nav |
| Memory | Grows unbounded | Fixed per page |
The Problem with Naive Implementation
Most developers start with something like this (don't use this):
TypeScript Version
import { useState, useEffect } from 'react';
// ❌ WRONG: Multiple critical issues
function InfiniteList() {
const [items, setItems] = useState<any[]>([]);
const [page, setPage] = useState(1);
useEffect(() => {
// Issues:
// 1. No loading state - multiple simultaneous requests
// 2. No cleanup - race conditions
// 3. No error handling
// 4. Fires on EVERY scroll event (performance disaster)
window.addEventListener('scroll', () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
fetch(`/api/items?page=${page}`)
.then(res => res.json())
.then(data => {
setItems(prev => [...prev, ...data]);
setPage(p => p + 1);
});
}
});
}, [page]);
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
);
}
What's broken:
- Scroll event performance: Fires hundreds of times per second
- No cleanup: Creates new scroll listener on every render
- Race conditions: Fast scrolling triggers multiple requests
- No loading state: Can't prevent duplicate requests
- Memory leak: Scroll listeners never removed
- Infinite re-renders: useEffect runs on every page change
IntersectionObserver: The Right Approach
IntersectionObserver is the modern solution for detecting when elements enter viewport. It's performant, battery-efficient, and designed exactly for this use case.
How IntersectionObserver Works
// Create observer
const observer = new IntersectionObserver(
(entries) => {
// entries[0].isIntersecting === true when element is visible
if (entries[0].isIntersecting) {
console.log('Element entered viewport!');
}
},
{
root: null, // null = viewport
rootMargin: '0px', // Trigger offset
threshold: 0.1, // 10% visible triggers callback
}
);
// Watch an element
const element = document.querySelector('#sentinel');
observer.observe(element);
// Stop watching
observer.unobserve(element);
observer.disconnect(); // Stop all observations
Why IntersectionObserver over scroll events:
- Fires only when visibility changes (not on every scroll)
- Runs off main thread (doesn't block rendering)
- Better battery life on mobile
- Handles complex layouts automatically
- Supports multiple threshold points
Basic Implementation with useEffect
Let's build it correctly from the start:
TypeScript Version
import { useState, useEffect, useRef, useCallback } from 'react';
interface Item {
id: number;
title: string;
}
function InfiniteList() {
const [items, setItems] = useState<Item[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
// Ref to track the sentinel element
const observerTarget = useRef<HTMLDivElement>(null);
// Load more data
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const response = await fetch(`/api/items?page=${page}`);
const newItems: Item[] = await response.json();
if (newItems.length === 0) {
setHasMore(false);
} else {
setItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
}
} catch (error) {
console.error('Failed to load items:', error);
} finally {
setLoading(false);
}
}, [page, loading, hasMore]);
// Set up IntersectionObserver
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMore();
}
},
{ threshold: 1.0 } // Trigger when fully visible
);
const currentTarget = observerTarget.current;
if (currentTarget) {
observer.observe(currentTarget);
}
// Cleanup: disconnect observer
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
};
}, [loadMore, hasMore, loading]);
return (
<div>
<ul>
{items.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
{/* Sentinel element - observed by IntersectionObserver */}
<div ref={observerTarget} style={{ height: '20px' }} />
{loading && <div>Loading more...</div>}
{!hasMore && <div>No more items</div>}
</div>
);
}
JavaScript Version
import { useState, useEffect, useRef, useCallback } from 'react';
function InfiniteList() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observerTarget = useRef(null);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const response = await fetch(`/api/items?page=${page}`);
const newItems = await response.json();
if (newItems.length === 0) {
setHasMore(false);
} else {
setItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
}
} catch (error) {
console.error('Failed to load items:', error);
} finally {
setLoading(false);
}
}, [page, loading, hasMore]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMore();
}
},
{ threshold: 1.0 }
);
const currentTarget = observerTarget.current;
if (currentTarget) {
observer.observe(currentTarget);
}
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
};
}, [loadMore, hasMore, loading]);
return (
<div>
<ul>
{items.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
<div ref={observerTarget} style={{ height: '20px' }} />
{loading && <div>Loading more...</div>}
{!hasMore && <div>No more items</div>}
</div>
);
}
Key improvements:
loadingstate prevents duplicate requestshasMoreflag stops fetching when no items remain- Observer only triggers when sentinel visible AND not loading
- Cleanup properly disconnects observer
- Sentinel element is dedicated div at bottom
Preventing Race Conditions
Fast scrolling can trigger multiple fetch requests before the first completes. This causes:
- Duplicate items
- Wrong page order
- Wasted bandwidth
- State corruption
TypeScript Version
import { useState, useEffect, useRef, useCallback } from 'react';
interface Item {
id: number;
title: string;
}
function InfiniteListWithAbort() {
const [items, setItems] = useState<Item[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observerTarget = useRef<HTMLDivElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
// Cancel previous request if still pending
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new abort controller for this request
abortControllerRef.current = new AbortController();
setLoading(true);
try {
const response = await fetch(`/api/items?page=${page}`, {
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const newItems: Item[] = await response.json();
if (newItems.length === 0) {
setHasMore(false);
} else {
setItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
}
} catch (error) {
// Don't show error if request was aborted
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Failed to load items:', error);
}
} finally {
setLoading(false);
}
}, [page, loading, hasMore]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMore();
}
},
{ threshold: 1.0 }
);
const currentTarget = observerTarget.current;
if (currentTarget) {
observer.observe(currentTarget);
}
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
// Abort any pending request when component unmounts
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [loadMore, hasMore, loading]);
return (
<div>
<ul>
{items.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
<div ref={observerTarget} style={{ height: '20px' }} />
{loading && <div>Loading more...</div>}
{!hasMore && <div>No more items</div>}
</div>
);
}
JavaScript Version
import { useState, useEffect, useRef, useCallback } from 'react';
function InfiniteListWithAbort() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observerTarget = useRef(null);
const abortControllerRef = useRef(null);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setLoading(true);
try {
const response = await fetch(`/api/items?page=${page}`, {
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const newItems = await response.json();
if (newItems.length === 0) {
setHasMore(false);
} else {
setItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Failed to load items:', error);
}
} finally {
setLoading(false);
}
}, [page, loading, hasMore]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMore();
}
},
{ threshold: 1.0 }
);
const currentTarget = observerTarget.current;
if (currentTarget) {
observer.observe(currentTarget);
}
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [loadMore, hasMore, loading]);
return (
<div>
<ul>
{items.map(item => (
<li key={item.id}>{item.title}</li>
))}
</ul>
<div ref={observerTarget} style={{ height: '20px' }} />
{loading && <div>Loading more...</div>}
{!hasMore && <div>No more items</div>}
</div>
);
}
Race condition prevention:
- Store
AbortControllerin ref (persists across renders) - Abort previous request before starting new one
- Pass signal to fetch
- Ignore
AbortError(expected behavior) - Cleanup aborts on unmount
Loading State Management
Production apps need more than just "loading" boolean:
TypeScript Version
import { useState, useRef, useCallback } from 'react';
type LoadingState = 'idle' | 'loading' | 'error' | 'success';
interface InfiniteState<T> {
items: T[];
page: number;
hasMore: boolean;
loadingState: LoadingState;
error: string | null;
}
function useInfiniteLoadingState<T>() {
const [state, setState] = useState<InfiniteState<T>>({
items: [],
page: 1,
hasMore: true,
loadingState: 'idle',
error: null,
});
const abortControllerRef = useRef<AbortController | null>(null);
const loadMore = useCallback(async (
fetchFn: (page: number) => Promise<T[]>
) => {
if (state.loadingState === 'loading' || !state.hasMore) return;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setState(prev => ({
...prev,
loadingState: 'loading',
error: null,
}));
try {
const newItems = await fetchFn(state.page);
setState(prev => ({
...prev,
items: [...prev.items, ...newItems],
page: prev.page + 1,
hasMore: newItems.length > 0,
loadingState: 'success',
}));
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
setState(prev => ({
...prev,
loadingState: 'error',
error: error.message,
}));
}
}
}, [state.page, state.hasMore, state.loadingState]);
const reset = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setState({
items: [],
page: 1,
hasMore: true,
loadingState: 'idle',
error: null,
});
}, []);
return {
...state,
loadMore,
reset,
};
}
JavaScript Version
import { useState, useRef, useCallback } from 'react';
function useInfiniteLoadingState() {
const [state, setState] = useState({
items: [],
page: 1,
hasMore: true,
loadingState: 'idle',
error: null,
});
const abortControllerRef = useRef(null);
const loadMore = useCallback(async (fetchFn) => {
if (state.loadingState === 'loading' || !state.hasMore) return;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setState(prev => ({
...prev,
loadingState: 'loading',
error: null,
}));
try {
const newItems = await fetchFn(state.page);
setState(prev => ({
...prev,
items: [...prev.items, ...newItems],
page: prev.page + 1,
hasMore: newItems.length > 0,
loadingState: 'success',
}));
} catch (error) {
if (error.name !== 'AbortError') {
setState(prev => ({
...prev,
loadingState: 'error',
error: error.message,
}));
}
}
}, [state.page, state.hasMore, state.loadingState]);
const reset = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setState({
items: [],
page: 1,
hasMore: true,
loadingState: 'idle',
error: null,
});
}, []);
return {
...state,
loadMore,
reset,
};
}
Benefits of state machine approach:
- Impossible states prevented (can't be loading AND error)
- Clear state transitions
- Easy to extend (add 'retrying' state)
- TypeScript discriminated unions for type safety
Error Handling and Retry Logic
Network failures happen. Production apps need retry capability:
TypeScript Version
import { useState, useRef, useCallback } from 'react';
interface UseInfiniteScrollOptions {
maxRetries?: number;
retryDelay?: number;
}
function useInfiniteScrollWithRetry<T>(
fetchFn: (page: number, signal: AbortSignal) => Promise<T[]>,
options: UseInfiniteScrollOptions = {}
) {
const { maxRetries = 3, retryDelay = 1000 } = options;
const [items, setItems] = useState<T[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(true);
const [retryCount, setRetryCount] = useState(0);
const abortControllerRef = useRef<AbortController | null>(null);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setLoading(true);
setError(null);
let attempt = 0;
while (attempt <= maxRetries) {
try {
const newItems = await fetchFn(
page,
abortControllerRef.current.signal
);
if (newItems.length === 0) {
setHasMore(false);
} else {
setItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
}
setRetryCount(0); // Reset retry count on success
setLoading(false);
return; // Success, exit
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
setLoading(false);
return; // Request cancelled, exit quietly
}
attempt++;
if (attempt > maxRetries) {
// All retries exhausted
setError(
err instanceof Error
? err.message
: 'Failed to load items'
);
setRetryCount(attempt);
setLoading(false);
return;
}
// Wait before retry (exponential backoff)
const delay = retryDelay * Math.pow(2, attempt - 1);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}, [page, loading, hasMore, fetchFn, maxRetries, retryDelay]);
const retry = useCallback(() => {
setError(null);
setRetryCount(0);
loadMore();
}, [loadMore]);
const reset = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setItems([]);
setPage(1);
setHasMore(true);
setLoading(false);
setError(null);
setRetryCount(0);
}, []);
return {
items,
loading,
error,
hasMore,
retryCount,
loadMore,
retry,
reset,
};
}
JavaScript Version
import { useState, useRef, useCallback } from 'react';
function useInfiniteScrollWithRetry(
fetchFn,
options = {}
) {
const { maxRetries = 3, retryDelay = 1000 } = options;
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [hasMore, setHasMore] = useState(true);
const [retryCount, setRetryCount] = useState(0);
const abortControllerRef = useRef(null);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setLoading(true);
setError(null);
let attempt = 0;
while (attempt <= maxRetries) {
try {
const newItems = await fetchFn(
page,
abortControllerRef.current.signal
);
if (newItems.length === 0) {
setHasMore(false);
} else {
setItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
}
setRetryCount(0);
setLoading(false);
return;
} catch (err) {
if (err.name === 'AbortError') {
setLoading(false);
return;
}
attempt++;
if (attempt > maxRetries) {
setError(err.message || 'Failed to load items');
setRetryCount(attempt);
setLoading(false);
return;
}
const delay = retryDelay * Math.pow(2, attempt - 1);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}, [page, loading, hasMore, fetchFn, maxRetries, retryDelay]);
const retry = useCallback(() => {
setError(null);
setRetryCount(0);
loadMore();
}, [loadMore]);
const reset = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setItems([]);
setPage(1);
setHasMore(true);
setLoading(false);
setError(null);
setRetryCount(0);
}, []);
return {
items,
loading,
error,
hasMore,
retryCount,
loadMore,
retry,
reset,
};
}
Retry strategy:
- Exponential backoff: 1s, 2s, 4s delays
- Max 3 retries by default (configurable)
- Manual retry button for user control
- Abort requests on retry/reset
- Clear error messages
Memory Optimization Techniques
Infinite scroll can accumulate thousands of DOM nodes, causing performance degradation:
TypeScript Version
import { useState, useEffect, useRef, useCallback } from 'react';
interface VirtualizedInfiniteProps<T> {
fetchPage: (page: number) => Promise<T[]>;
itemHeight: number;
maxItemsInMemory?: number;
}
function useVirtualizedInfinite<T>({
fetchPage,
itemHeight,
maxItemsInMemory = 200,
}: VirtualizedInfiniteProps<T>) {
const [allItems, setAllItems] = useState<T[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
// Only render items near viewport
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
const containerRef = useRef<HTMLDivElement>(null);
// Update visible range based on scroll
useEffect(() => {
const handleScroll = () => {
if (!containerRef.current) return;
const scrollTop = containerRef.current.scrollTop;
const visibleStart = Math.floor(scrollTop / itemHeight);
const visibleEnd = visibleStart + 50; // Show 50 items
setVisibleRange({ start: visibleStart, end: visibleEnd });
};
const container = containerRef.current;
if (container) {
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}
}, [itemHeight]);
// Prune old items if exceeding limit
useEffect(() => {
if (allItems.length > maxItemsInMemory) {
setAllItems(prev => prev.slice(-maxItemsInMemory));
}
}, [allItems.length, maxItemsInMemory]);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const newItems = await fetchPage(page);
if (newItems.length === 0) {
setHasMore(false);
} else {
setAllItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
}
} catch (error) {
console.error('Load failed:', error);
} finally {
setLoading(false);
}
}, [page, loading, hasMore, fetchPage]);
// Visible items slice
const visibleItems = allItems.slice(
visibleRange.start,
visibleRange.end
);
return {
allItems,
visibleItems,
visibleRange,
loading,
hasMore,
loadMore,
containerRef,
totalHeight: allItems.length * itemHeight,
};
}
JavaScript Version
import { useState, useEffect, useRef, useCallback } from 'react';
function useVirtualizedInfinite({
fetchPage,
itemHeight,
maxItemsInMemory = 200,
}) {
const [allItems, setAllItems] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
const containerRef = useRef(null);
useEffect(() => {
const handleScroll = () => {
if (!containerRef.current) return;
const scrollTop = containerRef.current.scrollTop;
const visibleStart = Math.floor(scrollTop / itemHeight);
const visibleEnd = visibleStart + 50;
setVisibleRange({ start: visibleStart, end: visibleEnd });
};
const container = containerRef.current;
if (container) {
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}
}, [itemHeight]);
useEffect(() => {
if (allItems.length > maxItemsInMemory) {
setAllItems(prev => prev.slice(-maxItemsInMemory));
}
}, [allItems.length, maxItemsInMemory]);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const newItems = await fetchPage(page);
if (newItems.length === 0) {
setHasMore(false);
} else {
setAllItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
}
} catch (error) {
console.error('Load failed:', error);
} finally {
setLoading(false);
}
}, [page, loading, hasMore, fetchPage]);
const visibleItems = allItems.slice(
visibleRange.start,
visibleRange.end
);
return {
allItems,
visibleItems,
visibleRange,
loading,
hasMore,
loadMore,
containerRef,
totalHeight: allItems.length * itemHeight,
};
}
Memory optimization strategies:
- Virtualization: Only render visible items
- Pruning: Remove old items beyond limit
- Fixed heights: Enables precise scroll calculations
- Throttled scroll: Update visible range sparingly
For simple implementations:
// Basic pruning without virtualization
useEffect(() => {
if (items.length > 500) {
setItems(prev => prev.slice(-300)); // Keep last 300
}
}, [items.length]);
Scroll Position Restoration
When navigating away and back, restore scroll position:
TypeScript Version
import { useEffect, useRef } from 'react';
function useScrollRestoration(key: string) {
const scrollPositions = useRef<Map<string, number>>(new Map());
// Save scroll position before navigation
useEffect(() => {
const handleBeforeUnload = () => {
const scrollY = window.scrollY;
scrollPositions.current.set(key, scrollY);
sessionStorage.setItem(
'scrollPositions',
JSON.stringify(Array.from(scrollPositions.current.entries()))
);
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
handleBeforeUnload(); // Save on unmount too
};
}, [key]);
// Restore scroll position on mount
useEffect(() => {
const saved = sessionStorage.getItem('scrollPositions');
if (saved) {
const entries = JSON.parse(saved);
scrollPositions.current = new Map(entries);
const position = scrollPositions.current.get(key);
if (position !== undefined) {
window.scrollTo(0, position);
}
}
}, [key]);
}
// Usage
function InfiniteListWithRestoration() {
useScrollRestoration('infinite-list');
// Rest of infinite scroll implementation...
}
JavaScript Version
import { useEffect, useRef } from 'react';
function useScrollRestoration(key) {
const scrollPositions = useRef(new Map());
useEffect(() => {
const handleBeforeUnload = () => {
const scrollY = window.scrollY;
scrollPositions.current.set(key, scrollY);
sessionStorage.setItem(
'scrollPositions',
JSON.stringify(Array.from(scrollPositions.current.entries()))
);
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
handleBeforeUnload();
};
}, [key]);
useEffect(() => {
const saved = sessionStorage.getItem('scrollPositions');
if (saved) {
const entries = JSON.parse(saved);
scrollPositions.current = new Map(entries);
const position = scrollPositions.current.get(key);
if (position !== undefined) {
window.scrollTo(0, position);
}
}
}, [key]);
}
Complete Custom Hook: useInfiniteScroll
Production-ready hook combining all patterns:
TypeScript Version
import { useState, useEffect, useRef, useCallback } from 'react';
interface UseInfiniteScrollOptions {
threshold?: number;
rootMargin?: string;
maxRetries?: number;
retryDelay?: number;
}
interface UseInfiniteScrollReturn<T> {
items: T[];
loading: boolean;
error: string | null;
hasMore: boolean;
sentinelRef: (node: HTMLElement | null) => void;
retry: () => void;
reset: () => void;
}
function useInfiniteScroll<T>(
fetchPage: (page: number, signal: AbortSignal) => Promise<T[]>,
options: UseInfiniteScrollOptions = {}
): UseInfiniteScrollReturn<T> {
const {
threshold = 1.0,
rootMargin = '0px',
maxRetries = 3,
retryDelay = 1000,
} = options;
const [items, setItems] = useState<T[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(true);
const observerRef = useRef<IntersectionObserver | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const sentinelNodeRef = useRef<HTMLElement | null>(null);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setLoading(true);
setError(null);
let attempt = 0;
while (attempt <= maxRetries) {
try {
const newItems = await fetchPage(
page,
abortControllerRef.current.signal
);
if (newItems.length === 0) {
setHasMore(false);
} else {
setItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
}
setLoading(false);
return;
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
setLoading(false);
return;
}
attempt++;
if (attempt > maxRetries) {
setError(
err instanceof Error ? err.message : 'Failed to load items'
);
setLoading(false);
return;
}
const delay = retryDelay * Math.pow(2, attempt - 1);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}, [page, loading, hasMore, fetchPage, maxRetries, retryDelay]);
const retry = useCallback(() => {
setError(null);
loadMore();
}, [loadMore]);
const reset = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setItems([]);
setPage(1);
setHasMore(true);
setLoading(false);
setError(null);
}, []);
// Callback ref for sentinel element
const sentinelRef = useCallback((node: HTMLElement | null) => {
// Disconnect previous observer
if (observerRef.current) {
observerRef.current.disconnect();
}
// Create new observer
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMore();
}
},
{ threshold, rootMargin }
);
// Observe new node
if (node) {
sentinelNodeRef.current = node;
observerRef.current.observe(node);
}
}, [hasMore, loading, loadMore, threshold, rootMargin]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
return {
items,
loading,
error,
hasMore,
sentinelRef,
retry,
reset,
};
}
export default useInfiniteScroll;
JavaScript Version
import { useState, useEffect, useRef, useCallback } from 'react';
function useInfiniteScroll(fetchPage, options = {}) {
const {
threshold = 1.0,
rootMargin = '0px',
maxRetries = 3,
retryDelay = 1000,
} = options;
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [hasMore, setHasMore] = useState(true);
const observerRef = useRef(null);
const abortControllerRef = useRef(null);
const sentinelNodeRef = useRef(null);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setLoading(true);
setError(null);
let attempt = 0;
while (attempt <= maxRetries) {
try {
const newItems = await fetchPage(
page,
abortControllerRef.current.signal
);
if (newItems.length === 0) {
setHasMore(false);
} else {
setItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
}
setLoading(false);
return;
} catch (err) {
if (err.name === 'AbortError') {
setLoading(false);
return;
}
attempt++;
if (attempt > maxRetries) {
setError(err.message || 'Failed to load items');
setLoading(false);
return;
}
const delay = retryDelay * Math.pow(2, attempt - 1);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}, [page, loading, hasMore, fetchPage, maxRetries, retryDelay]);
const retry = useCallback(() => {
setError(null);
loadMore();
}, [loadMore]);
const reset = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setItems([]);
setPage(1);
setHasMore(true);
setLoading(false);
setError(null);
}, []);
const sentinelRef = useCallback((node) => {
if (observerRef.current) {
observerRef.current.disconnect();
}
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMore();
}
},
{ threshold, rootMargin }
);
if (node) {
sentinelNodeRef.current = node;
observerRef.current.observe(node);
}
}, [hasMore, loading, loadMore, threshold, rootMargin]);
useEffect(() => {
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
return {
items,
loading,
error,
hasMore,
sentinelRef,
retry,
reset,
};
}
export default useInfiniteScroll;
Practical Example: Twitter-Style Feed
Real-world implementation with all features:
TypeScript Version
import useInfiniteScroll from './hooks/useInfiniteScroll';
interface Tweet {
id: string;
author: string;
content: string;
timestamp: string;
likes: number;
}
async function fetchTweets(
page: number,
signal: AbortSignal
): Promise<Tweet[]> {
const response = await fetch(
`https://api.example.com/tweets?page=${page}&limit=20`,
{ signal }
);
if (!response.ok) {
throw new Error(`Failed to fetch tweets: ${response.statusText}`);
}
const data = await response.json();
return data.tweets;
}
function TwitterFeed() {
const {
items: tweets,
loading,
error,
hasMore,
sentinelRef,
retry,
reset,
} = useInfiniteScroll<Tweet>(fetchTweets, {
threshold: 0.5, // Trigger when sentinel is 50% visible
rootMargin: '100px', // Start loading 100px before reaching bottom
});
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
}}>
<h1>Twitter Feed</h1>
<button
onClick={reset}
style={{
padding: '8px 16px',
backgroundColor: '#1DA1F2',
color: 'white',
border: 'none',
borderRadius: '20px',
cursor: 'pointer',
}}
>
Refresh
</button>
</div>
{/* Tweet list */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
{tweets.map((tweet) => (
<div
key={tweet.id}
style={{
border: '1px solid #e1e8ed',
borderRadius: '12px',
padding: '15px',
backgroundColor: 'white',
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '10px',
}}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '50%',
backgroundColor: '#1DA1F2',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontWeight: 'bold',
marginRight: '12px',
}}
>
{tweet.author[0].toUpperCase()}
</div>
<div>
<div style={{ fontWeight: 'bold' }}>{tweet.author}</div>
<div style={{ fontSize: '14px', color: '#657786' }}>
{new Date(tweet.timestamp).toLocaleDateString()}
</div>
</div>
</div>
<p style={{ margin: '10px 0', lineHeight: '1.5' }}>
{tweet.content}
</p>
<div style={{ fontSize: '14px', color: '#657786' }}>
❤️ {tweet.likes} likes
</div>
</div>
))}
</div>
{/* Sentinel element */}
<div
ref={sentinelRef}
style={{
height: '20px',
margin: '20px 0',
}}
/>
{/* Loading indicator */}
{loading && (
<div style={{
textAlign: 'center',
padding: '20px',
color: '#657786',
}}>
<div
style={{
width: '40px',
height: '40px',
border: '4px solid #f3f3f3',
borderTop: '4px solid #1DA1F2',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto',
}}
/>
<p style={{ marginTop: '10px' }}>Loading more tweets...</p>
</div>
)}
{/* Error state */}
{error && (
<div
style={{
backgroundColor: '#ffebee',
border: '1px solid #f44336',
borderRadius: '8px',
padding: '15px',
textAlign: 'center',
}}
>
<p style={{ color: '#c62828', margin: '0 0 10px 0' }}>
{error}
</p>
<button
onClick={retry}
style={{
padding: '8px 16px',
backgroundColor: '#f44336',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Retry
</button>
</div>
)}
{/* End of feed */}
{!hasMore && !loading && (
<div
style={{
textAlign: 'center',
padding: '20px',
color: '#657786',
}}
>
You've reached the end! 🎉
</div>
)}
{/* CSS animation */}
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
);
}
export default TwitterFeed;
JavaScript Version
import useInfiniteScroll from './hooks/useInfiniteScroll';
async function fetchTweets(page, signal) {
const response = await fetch(
`https://api.example.com/tweets?page=${page}&limit=20`,
{ signal }
);
if (!response.ok) {
throw new Error(`Failed to fetch tweets: ${response.statusText}`);
}
const data = await response.json();
return data.tweets;
}
function TwitterFeed() {
const {
items: tweets,
loading,
error,
hasMore,
sentinelRef,
retry,
reset,
} = useInfiniteScroll(fetchTweets, {
threshold: 0.5,
rootMargin: '100px',
});
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
{/* Same JSX as TypeScript version */}
</div>
);
}
export default TwitterFeed;
FAQ
Q: Should I use IntersectionObserver or scroll events?
A: Always use IntersectionObserver in production. Scroll events fire hundreds of times per second, requiring throttling/debouncing. IntersectionObserver is:
- More performant (runs off main thread)
- Battery-friendly on mobile
- Simpler to implement correctly
- Natively handles complex layouts
Only use scroll events if you need IE11 support and can't use a polyfill.
Q: How do I handle filtering or search with infinite scroll?
A: Reset the list when filter changes:
const [searchTerm, setSearchTerm] = useState('');
const { items, reset, sentinelRef } = useInfiniteScroll(
(page, signal) => fetchFiltered(page, searchTerm, signal)
);
useEffect(() => {
reset(); // Clear and restart when searchTerm changes
}, [searchTerm, reset]);
Q: What's the best threshold and rootMargin values?
A: It depends on use case:
// Conservative (less aggressive prefetch)
{ threshold: 1.0, rootMargin: '0px' }
// Moderate (recommended for most cases)
{ threshold: 0.5, rootMargin: '100px' }
// Aggressive (faster connections, smooth experience)
{ threshold: 0.1, rootMargin: '300px' }
Rule of thumb:
- Slow connections: Smaller rootMargin (100px)
- Fast connections: Larger rootMargin (300-500px)
- Image-heavy content: Larger rootMargin (500px+)
Q: How do I implement "Load More" button instead of automatic?
A: Detach observer and load manually:
function InfiniteWithButton() {
const { items, loading, loadMore, hasMore } = useInfiniteScroll(fetchItems);
return (
<div>
{items.map(item => <Item key={item.id} {...item} />)}
{hasMore && (
<button onClick={loadMore} disabled={loading}>
{loading ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
Q: How do I prevent memory issues with thousands of items?
A: Three approaches:
1. Virtualization (best for uniform items):
import { FixedSizeList } from 'react-window';
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={100}
width="100%"
>
{({ index, style }) => (
<div style={style}>{items[index].title}</div>
)}
</FixedSizeList>
2. Pruning (simple approach):
useEffect(() => {
if (items.length > 500) {
setItems(prev => prev.slice(-300)); // Keep last 300
}
}, [items.length]);
3. Pagination fallback:
if (items.length > 1000) {
// Switch to traditional pagination
}
Q: Can I use infinite scroll with React Router?
A: Yes, but save scroll position:
import { useLocation } from 'react-router-dom';
function InfiniteFeed() {
const location = useLocation();
const { items } = useInfiniteScroll(fetchItems);
useEffect(() => {
// Save scroll position
return () => {
sessionStorage.setItem(
`scroll-${location.pathname}`,
window.scrollY.toString()
);
};
}, [location.pathname]);
useEffect(() => {
// Restore scroll position
const saved = sessionStorage.getItem(`scroll-${location.pathname}`);
if (saved) {
window.scrollTo(0, parseInt(saved, 10));
}
}, [location.pathname]);
return <div>{/* Feed content */}</div>;
}
Related Articles:
- useEffect Deep Dive: Dependencies and Cleanup
- Custom Hooks Best Practices
- useRef Complete Guide
- React Performance Optimization
Questions? Share your infinite scroll implementations! How do you handle edge cases like rapid scrolling or slow connections?
Google AdSense Placeholder
CONTENT SLOT