The post feed needs two navigation patterns: pagination (explicit page numbers with next/prev controls) and infinite scroll (load more posts as the user reaches the bottom). Both patterns manage the same core concept — which posts to show — but with different UX. Pagination is better for precise navigation (“I want page 3”), infinite scroll for casual browsing. Custom hooks isolate these patterns so the PostFeed component can switch between them by swapping one hook for another.
usePagination
// src/hooks/usePagination.js
import { useState, useCallback } from "react";
export function usePagination({ initialPage = 1, totalPages = 1 } = {}) {
const [page, setPage] = useState(initialPage);
const goToPage = useCallback((newPage) => {
const clamped = Math.max(1, Math.min(newPage, totalPages));
setPage(clamped);
}, [totalPages]);
const nextPage = useCallback(() => goToPage(page + 1), [page, goToPage]);
const prevPage = useCallback(() => goToPage(page - 1), [page, goToPage]);
const firstPage = useCallback(() => goToPage(1), [goToPage]);
const lastPage = useCallback(() => goToPage(totalPages), [goToPage, totalPages]);
return {
page,
totalPages,
isFirstPage: page === 1,
isLastPage: page === totalPages,
goToPage,
nextPage,
prevPage,
firstPage,
lastPage,
};
}
// Pagination UI component using the hook
function Pagination({ page, totalPages, onPageChange }) {
const pages = Array.from({ length: totalPages }, (_, i) => i + 1);
// For large page counts, show only a window around the current page
const visible = pages.filter(
(p) => p === 1 || p === totalPages || Math.abs(p - page) <= 2
);
return (
<nav className="flex items-center gap-1 justify-center mt-8">
<button onClick={() => onPageChange(page - 1)} disabled={page === 1}
className="px-3 py-1 rounded border disabled:opacity-40">
‹
</button>
{visible.map((p, i) => (
<>
{i > 0 && visible[i - 1] !== p - 1 && (
<span key={`gap-${p}`} className="px-1 text-gray-400">…</span>
)}
<button key={p} onClick={() => onPageChange(p)}
className={`px-3 py-1 rounded border
${p === page ? "bg-blue-600 text-white" : ""}`}>
{p}
</button>
</>
))}
<button onClick={() => onPageChange(page + 1)} disabled={page === totalPages}
className="px-3 py-1 rounded border disabled:opacity-40">
›
</button>
</nav>
);
}
Note:
usePagination does not fetch data — it only manages the page number. The component using it passes page to its data hook (e.g., useGetPostsQuery({ page })) which handles the actual fetching. This separation keeps each hook focused on one concern: usePagination manages navigation state, the data hook manages fetching. They compose together in the component without coupling their logic.Tip: Sync the current page to the URL search params (
?page=2) so users can bookmark and share paginated pages, and so the browser back button works correctly. Replace the useState(initialPage) in usePagination with useSearchParams: const [searchParams, setSearchParams] = useSearchParams(); const page = Number(searchParams.get("page") ?? 1); and update via setSearchParams({ page: newPage }). This one change makes the pagination URL-persistent.Warning: Infinite scroll with
IntersectionObserver must be cleaned up when the component unmounts. The observer holds a reference to the sentinel DOM element and fires callbacks even after navigation away from the page. Always return a cleanup function: return () => { if (sentinelRef.current) observer.unobserve(sentinelRef.current); observer.disconnect(); }. Without cleanup, stale callbacks can try to update state on unmounted components.useInfiniteScroll
// src/hooks/useInfiniteScroll.js
import { useState, useEffect, useRef, useCallback } from "react";
export function useInfiniteScroll({ fetchMore, hasMore, isLoading }) {
const sentinelRef = useRef(null); // invisible div at the bottom of the list
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry.isIntersecting && hasMore && !isLoading) {
fetchMore();
}
},
{ rootMargin: "200px" } // trigger 200px before the sentinel is visible
);
observer.observe(sentinel);
return () => {
observer.unobserve(sentinel);
observer.disconnect();
};
}, [fetchMore, hasMore, isLoading]);
return sentinelRef;
}
// Component using infinite scroll
function InfinitePostFeed({ tag }) {
const [page, setPage] = useState(1);
const [allPosts, setAllPosts] = useState([]);
const { data, isLoading } = useGetPostsQuery({ page, tag });
const hasMore = data ? page < data.pages : false;
// Append new posts when page increases
useEffect(() => {
if (data?.items) {
setAllPosts((prev) =>
page === 1 ? data.items : [...prev, ...data.items]
);
}
}, [data, page]);
const fetchMore = useCallback(() => {
if (!isLoading && hasMore) setPage((p) => p + 1);
}, [isLoading, hasMore]);
const sentinelRef = useInfiniteScroll({ fetchMore, hasMore, isLoading });
return (
<div>
<PostList posts={allPosts} isLoading={isLoading && page === 1} />
{isLoading && page > 1 && (
<div className="text-center py-4 text-gray-400">Loading more...</div>
)}
{/* Invisible sentinel element — observer watches this */}
<div ref={sentinelRef} className="h-1" />
</div>
);
}
Common Mistakes
Mistake 1 — Not disconnecting IntersectionObserver (stale callbacks)
❌ Wrong — observer fires after unmount, updates state on dead component:
const observer = new IntersectionObserver(callback);
observer.observe(el);
// No cleanup! Observer fires forever, even after navigation away.
✅ Correct — always disconnect in cleanup:
return () => { observer.unobserve(el); observer.disconnect(); }; // ✓
Mistake 2 — Not resetting posts array when filter changes
❌ Wrong — old posts prepend to new ones when tag filter changes:
setAllPosts(prev => [...prev, ...data.items]); // appends regardless of page number!
✅ Correct — reset to page 1 and clear posts when filter changes.
Quick Reference
| Hook | Returns | Use For |
|---|---|---|
usePagination({ totalPages }) |
page, nextPage, prevPage, goToPage, isFirstPage, isLastPage | Page-based navigation |
useInfiniteScroll({ fetchMore, hasMore, isLoading }) |
sentinelRef | Auto-load on scroll |