Infinite lists
useInfiniteQuery with the API's own next-page value, and one shared hook for the scroll trigger.
From: the multi-vendor e-commerce platform (mobile order history), and the company website (blog grid page size).
Page numbers are awkward on a phone. Mobile lists load more as you scroll,
or with a "Load more" button. Both use
useInfiniteQuery.
The query
export function useOrderHistoryInfinite() {
return useInfiniteQuery({
queryKey: queryKeys.orderHistory.infinite(),
queryFn: ({ pageParam }) => getOrderHistory(pageParam, MOBILE_PAGE_SIZE),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
});
}The API sends nextPage with every page. Returning undefined from
getNextPageParam tells TanStack Query there's nothing left, which sets
hasNextPage to false.
Join the pages into one list in the component:
const orders = data?.pages.flatMap((page) => page.orders) ?? [];Give the infinite query its own key, even when it calls the same endpoint as
a paginated useQuery. See Query keys.
The scroll trigger, written once
An empty div at the end of the list tells us when the user is near the
bottom. Every scrolling list uses the same hook:
export function useInfiniteScrollTrigger({
hasNextPage,
isFetchingNextPage,
fetchNextPage,
}: Options) {
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = sentinelRef.current;
if (!(el && hasNextPage)) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting && !isFetchingNextPage) fetchNextPage();
},
{ rootMargin: '200px' },
);
observer.observe(el);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return sentinelRef;
}const sentinelRef = useInfiniteScrollTrigger({ hasNextPage, isFetchingNextPage, fetchNextPage });
return (
<>
<OrderList orders={orders} />
<div ref={sentinelRef} aria-hidden="true" />
{isFetchingNextPage ? <Spinner /> : null}
</>
);rootMargin: '200px'starts loading a little before the end, so the next items are often there before they're needed.- The
isFetchingNextPagecheck stops a second request while one is already on its way. - The
!hasNextPageearly return means the observer is removed once everything has loaded.
A page size that fills the grid
On the company website's blog page, posts load in batches into a grid with one column on phones, two on tablets and three on desktop. A fixed batch of 9 leaves a gap on tablets: 9 posts in 2 columns is 4 full rows and one post on its own.
The page size follows the grid instead, and each size divides evenly by that breakpoint's column count:
/** Matches the blogs grid's own `sm`/`lg` column breakpoints. */
const TABLET_BREAKPOINT_PX = 640;
const DESKTOP_BREAKPOINT_PX = 1024;
const MOBILE_PAGE_SIZE = 6;
const TABLET_PAGE_SIZE = 8;
function resolvePageSize(viewportWidth: number) {
if (viewportWidth < TABLET_BREAKPOINT_PX) return MOBILE_PAGE_SIZE;
if (viewportWidth < DESKTOP_BREAKPOINT_PX) return TABLET_PAGE_SIZE;
return BLOG_LIST_PAGE_SIZE; // 9
}
export function useBlogGridPageSize() {
// Start with the desktop size so the first render matches the
// server-prefetched query.
const [pageSize, setPageSize] = useState(BLOG_LIST_PAGE_SIZE);
useEffect(() => {
const update = () => setPageSize(resolvePageSize(window.innerWidth));
update();
const tablet = window.matchMedia(`(min-width: ${TABLET_BREAKPOINT_PX}px)`);
const desktop = window.matchMedia(`(min-width: ${DESKTOP_BREAKPOINT_PX}px)`);
tablet.addEventListener('change', update);
desktop.addEventListener('change', update);
return () => {
tablet.removeEventListener('change', update);
desktop.removeEventListener('change', update);
};
}, []);
return pageSize;
}const pageSize = useBlogGridPageSize();
const query = useSuspenseInfiniteQuery(blogListInfiniteQueryOptions({ limit: pageSize }));The page size is part of the query key, so changing it starts a fresh list.
The trade-off
The server doesn't know the screen width, so it prefetches the desktop size. The hook starts with that same size, so the first render uses the hydrated data. On phones and tablets the size then changes once after mount, which means one new request and a brief skeleton. A full grid was worth it here; for a list where the first paint matters more, keep one size everywhere.
matchMedia listeners fire only when a breakpoint is crossed, unlike a
resize listener, which fires on every pixel.
The same day, I moved this into a shared useGridPageSize hook in
src/hooks/, which takes the sizes as options. The blog, case study and
portfolio grids all use it now.
Last updated on