Hooks for managing, caching and syncing asynchronous and remote data in React
—
Specialized query hook for paginated data with infinite scrolling support, automatic page management, and cursor-based pagination. The useInfiniteQuery hook is perfect for implementing "Load More" buttons or infinite scroll functionality.
The main hook for fetching paginated data with automatic pagination management.
/**
* Fetch paginated data with infinite loading support
* @param options - Infinite query configuration options
* @returns Infinite query result with pagination utilities
*/
function useInfiniteQuery<TQueryFnData = unknown, TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>(
options: UseInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryFnData, TQueryKey>
): UseInfiniteQueryResult<TData, TError>;
/**
* Fetch paginated data using separate queryKey and queryFn parameters
* @param queryKey - Unique identifier for the query
* @param queryFn - Function that returns paginated data
* @param options - Additional infinite query configuration
* @returns Infinite query result with pagination utilities
*/
function useInfiniteQuery<TQueryFnData = unknown, TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey>(
queryKey: TQueryKey,
queryFn: QueryFunction<TQueryFnData, TQueryKey>,
options?: Omit<UseInfiniteQueryOptions<TQueryFnData, TError, TData, TQueryFnData, TQueryKey>, 'queryKey' | 'queryFn'>
): UseInfiniteQueryResult<TData, TError>;Usage Examples:
import { useInfiniteQuery } from "react-query";
// Basic infinite query with cursor-based pagination
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) =>
fetch(`/api/posts?cursor=${pageParam}&limit=10`)
.then(res => res.json()),
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined
});
// Infinite query with page-based pagination
const {
data: searchResults,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useInfiniteQuery({
queryKey: ['search', searchTerm],
queryFn: ({ pageParam = 1 }) =>
fetch(`/api/search?q=${searchTerm}&page=${pageParam}&limit=20`)
.then(res => res.json()),
getNextPageParam: (lastPage, allPages) => {
return lastPage.hasMore ? allPages.length + 1 : undefined;
},
enabled: !!searchTerm
});
// Using the paginated data
const allPosts = data?.pages.flatMap(page => page.posts) ?? [];
return (
<div>
{allPosts.map(post => (
<div key={post.id}>{post.title}</div>
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading more...' : 'Load More'}
</button>
)}
</div>
);The return value from useInfiniteQuery containing paginated data and navigation utilities.
interface UseInfiniteQueryResult<TData = unknown, TError = unknown> {
/** Paginated data structure with pages and page parameters */
data: InfiniteData<TData> | undefined;
/** Error object if the query failed */
error: TError | null;
/** Function to fetch the next page */
fetchNextPage: (options?: FetchNextPageOptions) => Promise<UseInfiniteQueryResult<TData, TError>>;
/** Function to fetch the previous page */
fetchPreviousPage: (options?: FetchPreviousPageOptions) => Promise<UseInfiniteQueryResult<TData, TError>>;
/** True if there are more pages to fetch forward */
hasNextPage: boolean;
/** True if there are more pages to fetch backward */
hasPreviousPage: boolean;
/** True if currently fetching next page */
isFetchingNextPage: boolean;
/** True if currently fetching previous page */
isFetchingPreviousPage: boolean;
/** True if this is the first time the query is loading */
isLoading: boolean;
/** True if the query is pending (loading or paused) */
isPending: boolean;
/** True if the query succeeded and has data */
isSuccess: boolean;
/** True if the query is in an error state */
isError: boolean;
/** True if the query is currently fetching any page */
isFetching: boolean;
/** True if the query is refetching in the background */
isRefetching: boolean;
/** Current status of the query */
status: 'pending' | 'error' | 'success';
/** Current fetch status */
fetchStatus: 'fetching' | 'paused' | 'idle';
/** Function to manually refetch all pages */
refetch: () => Promise<UseInfiniteQueryResult<TData, TError>>;
/** Function to remove the query from cache */
remove: () => void;
/** True if query data is stale */
isStale: boolean;
/** True if data exists in cache */
isPlaceholderData: boolean;
/** True if using previous data during refetch */
isPreviousData: boolean;
/** Timestamp when data was last updated */
dataUpdatedAt: number;
/** Timestamp when error occurred */
errorUpdatedAt: number;
/** Number of times query has failed */
failureCount: number;
/** Reason query is paused */
failureReason: TError | null;
/** True if currently paused */
isPaused: boolean;
}
interface InfiniteData<TData> {
/** Array of page data */
pages: TData[];
/** Array of page parameters used to fetch each page */
pageParams: unknown[];
}
interface FetchNextPageOptions {
/** Cancel ongoing requests before fetching */
cancelRefetch?: boolean;
}
interface FetchPreviousPageOptions {
/** Cancel ongoing requests before fetching */
cancelRefetch?: boolean;
}Configuration options for useInfiniteQuery hook.
interface UseInfiniteQueryOptions<TQueryFnData = unknown, TError = unknown, TData = TQueryFnData, TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey> {
/** Unique identifier for the query */
queryKey?: TQueryKey;
/** Function that returns paginated data */
queryFn?: QueryFunction<TQueryFnData, TQueryKey>;
/** Function to get the next page parameter */
getNextPageParam: GetNextPageParamFunction<TQueryFnData>;
/** Function to get the previous page parameter */
getPreviousPageParam?: GetPreviousPageParamFunction<TQueryFnData>;
/** Whether the query should run automatically */
enabled?: boolean;
/** Retry configuration */
retry?: boolean | number | ((failureCount: number, error: TError) => boolean);
/** Delay between retries */
retryDelay?: number | ((retryAttempt: number, error: TError) => number);
/** Time before data is considered stale */
staleTime?: number;
/** Time before inactive queries are garbage collected */
cacheTime?: number;
/** Interval for automatic refetching */
refetchInterval?: number | false;
/** Whether to refetch when window is not focused */
refetchIntervalInBackground?: boolean;
/** When to refetch on component mount */
refetchOnMount?: boolean | "always";
/** When to refetch on window focus */
refetchOnWindowFocus?: boolean | "always";
/** When to refetch on network reconnection */
refetchOnReconnect?: boolean | "always";
/** Transform or select data subset */
select?: (data: InfiniteData<TQueryFnData>) => TData;
/** Initial data to use before first fetch */
initialData?: InfiniteData<TQueryFnData> | (() => InfiniteData<TQueryFnData>);
/** Placeholder data while loading */
placeholderData?: InfiniteData<TQueryFnData> | (() => InfiniteData<TQueryFnData>);
/** Callback on successful query */
onSuccess?: (data: InfiniteData<TQueryFnData>) => void;
/** Callback on query error */
onError?: (error: TError) => void;
/** Callback after query settles (success or error) */
onSettled?: (data: InfiniteData<TQueryFnData> | undefined, error: TError | null) => void;
/** React context for QueryClient */
context?: React.Context<QueryClient | undefined>;
/** Whether to throw errors to error boundaries */
useErrorBoundary?: boolean | ((error: TError) => boolean);
/** Whether to suspend component rendering */
suspense?: boolean;
/** Whether to keep previous data during refetch */
keepPreviousData?: boolean;
/** Additional metadata */
meta?: QueryMeta;
/** Maximum number of pages to keep in memory */
maxPages?: number;
}Functions that determine pagination parameters.
type GetNextPageParamFunction<TQueryFnData = unknown> = (
lastPage: TQueryFnData,
allPages: TQueryFnData[]
) => unknown;
type GetPreviousPageParamFunction<TQueryFnData = unknown> = (
firstPage: TQueryFnData,
allPages: TQueryFnData[]
) => unknown;Automatic loading when user scrolls near the bottom:
import { useInfiniteQuery } from "react-query";
import { useIntersectionObserver } from "./hooks/useIntersectionObserver";
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) =>
fetch(`/api/posts?cursor=${pageParam}&limit=10`)
.then(res => res.json()),
getNextPageParam: (lastPage) => lastPage.nextCursor
});
// Intersection observer to trigger loading
const { ref, entry } = useIntersectionObserver({
threshold: 0.1,
rootMargin: '100px'
});
// Fetch next page when sentinel comes into view
React.useEffect(() => {
if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [entry, fetchNextPage, hasNextPage, isFetchingNextPage]);
if (isLoading) return <div>Loading...</div>;
const allPosts = data?.pages.flatMap(page => page.posts) ?? [];
return (
<div>
{allPosts.map(post => (
<PostCard key={post.id} post={post} />
))}
{/* Sentinel element for intersection observer */}
<div ref={ref} style={{ height: '20px' }}>
{isFetchingNextPage && <div>Loading more posts...</div>}
</div>
{!hasNextPage && (
<div>No more posts to load</div>
)}
</div>
);
}Supporting both forward and backward pagination:
function ChatMessages({ channelId }: { channelId: string }) {
const {
data,
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage
} = useInfiniteQuery({
queryKey: ['messages', channelId],
queryFn: ({ pageParam }) => {
const { cursor, direction = 'newer' } = pageParam || {};
return fetch(`/api/channels/${channelId}/messages?cursor=${cursor}&direction=${direction}&limit=50`)
.then(res => res.json());
},
getNextPageParam: (lastPage) =>
lastPage.hasNewer ? { cursor: lastPage.newestCursor, direction: 'newer' } : undefined,
getPreviousPageParam: (firstPage) =>
firstPage.hasOlder ? { cursor: firstPage.oldestCursor, direction: 'older' } : undefined,
select: (data) => ({
pages: [...data.pages].reverse(), // Show oldest messages first
pageParams: data.pageParams
})
});
const allMessages = data?.pages.flatMap(page => page.messages) ?? [];
return (
<div className="chat-container">
{hasPreviousPage && (
<button
onClick={() => fetchPreviousPage()}
disabled={isFetchingPreviousPage}
>
{isFetchingPreviousPage ? 'Loading older...' : 'Load Older Messages'}
</button>
)}
{allMessages.map(message => (
<MessageComponent key={message.id} message={message} />
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading newer...' : 'Load Newer Messages'}
</button>
)}
</div>
);
}Combining search functionality with infinite loading:
function InfiniteSearch() {
const [searchTerm, setSearchTerm] = useState('');
const [debouncedTerm, setDebouncedTerm] = useState('');
// Debounce search term
useEffect(() => {
const timer = setTimeout(() => setDebouncedTerm(searchTerm), 300);
return () => clearTimeout(timer);
}, [searchTerm]);
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
refetch
} = useInfiniteQuery({
queryKey: ['search', debouncedTerm],
queryFn: ({ pageParam = 1 }) =>
fetch(`/api/search?q=${debouncedTerm}&page=${pageParam}&limit=20`)
.then(res => res.json()),
getNextPageParam: (lastPage, allPages) =>
lastPage.hasMore ? allPages.length + 1 : undefined,
enabled: debouncedTerm.length > 2,
keepPreviousData: true // Keep showing old results while new search loads
});
const allResults = data?.pages.flatMap(page => page.results) ?? [];
return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
{isLoading && <div>Searching...</div>}
{allResults.length > 0 && (
<div>
<p>{data.pages[0].totalCount} results found</p>
{allResults.map(result => (
<SearchResult key={result.id} result={result} />
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading more...' : 'Load More Results'}
</button>
)}
</div>
)}
{debouncedTerm.length > 2 && allResults.length === 0 && !isLoading && (
<div>No results found for "{debouncedTerm}"</div>
)}
</div>
);
}Limiting pages in memory to prevent memory leaks:
const { data } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
getNextPageParam: (lastPage) => lastPage.nextCursor,
maxPages: 10, // Only keep 10 pages in memory
select: (data) => ({
pages: data.pages,
pageParams: data.pageParams
})
});
// Or manually clean up old pages
const { data, fetchNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
getNextPageParam: (lastPage) => lastPage.nextCursor,
onSuccess: (data) => {
// Keep only last 5 pages
if (data.pages.length > 5) {
const newData = {
pages: data.pages.slice(-5),
pageParams: data.pageParams.slice(-5)
};
queryClient.setQueryData(['posts'], newData);
}
}
});Install with Tessl CLI
npx tessl i tessl/npm-react-query