Hook composition is the most powerful application of custom hooks — building a complex, feature-rich hook by combining several simpler ones. A usePostFeed hook for the blog’s main page needs search, debouncing, tag filtering, pagination, and data fetching — each of which is already encapsulated in a dedicated hook. Composing them together produces a single hook with a clean interface that the page component calls with one line, keeping the page component focused entirely on layout and rendering.
usePostFeed — Composing Multiple Hooks
// src/hooks/usePostFeed.js
import { useState, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { useGetPostsQuery } from "@/store/apiSlice";
import { useDebounce } from "@/hooks/useDebounce";
export function usePostFeed() {
const [searchParams, setSearchParams] = useSearchParams();
// Read filter state from URL (shareable, bookmarkable)
const page = Number(searchParams.get("page") ?? 1);
const tag = searchParams.get("tag") ?? null;
const search = searchParams.get("search") ?? "";
// Debounce the search query — only fetch 300ms after typing stops
const debouncedSearch = useDebounce(search, 300);
// Fetch posts from RTK Query (auto-caches, deduplicates)
const {
data,
isLoading,
isFetching,
isError,
error,
} = useGetPostsQuery({
page,
tag: tag ?? undefined,
search: debouncedSearch || undefined,
pageSize: 10,
});
// ── URL-based navigation helpers ──────────────────────────────────────────
function setPage(newPage) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("page", String(newPage));
return next;
});
}
function setTag(newTag) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (newTag) { next.set("tag", newTag); } else { next.delete("tag"); }
next.set("page", "1"); // reset to page 1 on filter change
return next;
});
}
function setSearch(newSearch) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (newSearch) { next.set("search", newSearch); } else { next.delete("search"); }
next.set("page", "1");
return next;
});
}
return {
// Data
posts: data?.items ?? [],
total: data?.total ?? 0,
totalPages: data?.pages ?? 0,
// Status
isLoading: isLoading,
isFetching: isFetching && !isLoading, // background refetch
isError,
error: error?.data?.detail ?? "Failed to load posts",
// Current filter state
page,
tag,
search,
// Setters
setPage,
setTag,
setSearch,
};
}
Note: By storing filter state in the URL (
useSearchParams) rather than useState, the feed state becomes shareable and navigable. A user can bookmark /?tag=python&page=2, press the browser back button to return to the unfiltered feed, and share a link to a specific filtered view. This is a significant UX improvement over local state with zero extra complexity in the component — the URL is the state.Tip: Export a barrel file from the hooks directory:
src/hooks/index.js with export { useDebounce } from "./useDebounce"; export { useForm } from "./useForm"; .... Components then import as import { useForm, useDebounce } from "@/hooks" — clean, discoverable, and you can reorganise the hook files without updating imports throughout the codebase.Warning: Avoid creating hooks that do too much — a hook with 20 state variables, 10 effects, and a 200-line implementation is a sign that the abstraction needs to be split. A good hook is roughly the size of a good function: focused, testable, and composable. If a hook is getting large, ask: “can I extract the data fetching into one hook and the filter state into another?” Composition of small hooks beats one large hook every time.
The Page Component — Thin After Hook Composition
// src/pages/HomePage.jsx — thin component after hook composition
import { usePostFeed } from "@/hooks/usePostFeed";
import PostList from "@/components/post/PostList";
import TagFilter from "@/components/post/TagFilter";
import SearchBar from "@/components/ui/SearchBar";
import Pagination from "@/components/ui/Pagination";
export default function HomePage() {
const {
posts, total, totalPages, isLoading, isError, error,
page, tag, search,
setPage, setTag, setSearch,
} = usePostFeed();
return (
<div className="space-y-6">
<div className="flex gap-4">
<SearchBar value={search} onChange={setSearch} />
</div>
<TagFilter activeTag={tag} onSelect={setTag} />
<PostList posts={posts} isLoading={isLoading} error={isError ? error : null} />
{totalPages > 1 && (
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
)}
</div>
);
}
// The entire page is 25 lines — all logic lives in the hook.
Hook Composition Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| One giant hook | Hard to test, understand, reuse | Split into focused hooks, compose |
| Hooks that return too much | Consumers can’t tell what they need | Return only what callers use |
| Hooks with side effects in render | Bugs from unexpected side effects | Side effects only in useEffect |
| Calling hooks from hooks conditionally | Breaks Rules of Hooks | Call unconditionally, guard inside |
| Duplicating logic between hook and component | Two sources of truth | Move all logic to the hook |
Complete Hooks Library for the Blog Application
| Hook | Purpose |
|---|---|
useDebounce(value, ms) |
Delay a value update |
useLocalStorage(key, init) |
Persistent state |
useMediaQuery(query) |
CSS media query in JS |
useOnline() |
Network connectivity |
useForm({ initialValues, validate }) |
Form state management |
usePagination({ totalPages }) |
Page navigation |
useInfiniteScroll({ fetchMore, hasMore }) |
Auto-load on scroll |
usePostFeed() |
Composed: search + filter + fetch + pagination |