typescriptadvanced
useInfiniteQuery Hook
Implement infinite scrolling with cursor-based pagination, loading states, and intersection observer.
typescriptPress ⌘/Ctrl + Shift + C to copy
import { useCallback, useEffect, useRef, useState } from 'react';
interface Page<T> {
data: T[];
nextCursor: string | null;
}
interface InfiniteQueryResult<T> {
data: T[];
loading: boolean;
loadingMore: boolean;
error: Error | null;
hasMore: boolean;
loadMore: () => void;
sentinelRef: (node: HTMLElement | null) => void;
}
function useInfiniteQuery<T>(
fetcher: (cursor?: string) => Promise<Page<T>>,
deps: unknown[] = [],
): InfiniteQueryResult<T> {
const [pages, setPages] = useState<Page<T>[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<Error | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const cursorRef = useRef<string | null>(null);
const isFetchingRef = useRef(false);
// Initial fetch
useEffect(() => {
setLoading(true);
setPages([]);
cursorRef.current = null;
fetcher()
.then((page) => {
setPages([page]);
cursorRef.current = page.nextCursor;
setLoading(false);
})
.catch((err) => { setError(err); setLoading(false); });
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
const loadMore = useCallback(async () => {
if (isFetchingRef.current || !cursorRef.current) return;
isFetchingRef.current = true;
setLoadingMore(true);
try {
const page = await fetcher(cursorRef.current);
setPages((prev) => [...prev, page]);
cursorRef.current = page.nextCursor;
} catch (err) {
setError(err as Error);
} finally {
setLoadingMore(false);
isFetchingRef.current = false;
}
}, [fetcher]);
// IntersectionObserver sentinel ref callback
const sentinelRef = useCallback(
(node: HTMLElement | null) => {
if (observerRef.current) observerRef.current.disconnect();
if (!node) return;
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && cursorRef.current) loadMore();
},
{ rootMargin: '200px' },
);
observerRef.current.observe(node);
},
[loadMore],
);
const data = pages.flatMap((p) => p.data);
const hasMore = cursorRef.current !== null;
return { data, loading, loadingMore, error, hasMore, loadMore, sentinelRef };
}
// Usage
// function Feed() {
// const { data, loading, loadingMore, hasMore, sentinelRef } = useInfiniteQuery(
// (cursor) => fetchPosts(cursor),
// );
// return (
// <div>
// {data.map(post => <PostCard key={post.id} {...post} />)}
// {hasMore && <div ref={sentinelRef} className="h-4" />}
// {loadingMore && <Spinner />}
// </div>
// );
// }
export { useInfiniteQuery };Use Cases
- Infinite scroll feeds
- Paginated API lists
- Social media timelines
Tags
Related Snippets
Similar patterns you can reuse in the same workflow.
typescriptintermediate
Infinite Scroll Hook v2
Intersection Observer-based infinite scroll with loading, error, and hasMore states.
Best for: Feed-style content
#hooks#infinite-scroll
typescriptintermediate
Infinite Scroll with Intersection Observer
Load more items as the user scrolls using IntersectionObserver. No external libraries required.
Best for: Feed pagination
#infinite-scroll#intersection-observer
typescriptbeginner
usePrevious Hook
Track the previous value of any state or prop using a ref-based hook for comparison logic.
Best for: Detecting value changes
#hooks#state
typescriptintermediate
React Custom Hook for Data Fetching
A reusable useFetch hook with loading states, error handling, caching, and abort support.
Best for: Reusable data fetching across components
#react#hooks