Hook Composition — Building Complex Hooks from Simple Ones

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

🧠 Test Yourself

The usePostFeed hook stores page, tag, and search in the URL via useSearchParams instead of useState. What is the key UX benefit?