Custom Data Hooks — Reusable Fetching Logic

The data-fetching pattern — useState for data/loading/error, useEffect for the fetch, AbortController for cleanup — appears in every component that loads data. Copying and pasting this 20-line pattern into every component creates maintenance burden: when the error handling changes, you update it everywhere. Custom hooks extract this repeated pattern into a single function with a clean interface. Components call const { posts, isLoading, error } = usePosts(page, tag) — one line to get all the state they need — and the hook handles all the complexity internally.

Building Custom Data Hooks

// src/hooks/usePosts.js
import { useState, useEffect } from "react";
import { postsApi } from "@/services/posts";

export function usePosts({ page = 1, pageSize = 10, tag = null, search = null } = {}) {
    const [data,      setData]      = useState(null);
    const [isLoading, setIsLoading] = useState(true);
    const [error,     setError]     = useState(null);

    // Allow manual refresh
    const [refreshKey, setRefreshKey] = useState(0);
    const refetch = () => setRefreshKey((k) => k + 1);

    useEffect(() => {
        const controller = new AbortController();
        setIsLoading(true);
        setError(null);

        postsApi.list({ page, page_size: pageSize, tag, search })
            .then((response) => {
                setData(response);
                setIsLoading(false);
            })
            .catch((err) => {
                if (err.name === "CanceledError") return;
                setError(err.response?.data?.detail ?? err.message ?? "Failed to load posts");
                setIsLoading(false);
            });

        return () => controller.abort();
    }, [page, pageSize, tag, search, refreshKey]);

    return {
        posts:      data?.items ?? [],
        total:      data?.total ?? 0,
        pages:      data?.pages ?? 0,
        isLoading,
        error,
        refetch,
    };
}
Note: Custom hooks are just regular JavaScript functions whose names start with use (the use prefix is a convention that React’s linter enforces). They can call other hooks (useState, useEffect, other custom hooks), they can accept arguments and return any value. The returned object is a clean interface: components only see the data they need, not the implementation details. The hook can be refactored, optimised, or replaced with TanStack Query without changing any component.
Tip: Add a refetch function to your data hooks using a refreshKey state variable included in the dependency array. When a component needs to reload the data (after creating a post, after toggling a like), it calls refetch(), which increments refreshKey, which triggers the effect. This pattern provides manual refetch capability without the complexity of TanStack Query’s cache invalidation system.
Warning: Custom hooks do not cache data. If usePosts({ page: 1 }) is called in two different components, two separate API requests are made. This is fine for learning and small applications, but in production you will want TanStack Query’s intelligent caching — the same query from multiple components only fetches once, and all components update when the cache is refreshed. Chapter 41 introduces TanStack Query as the production data-fetching solution.

usePost — Single Resource Hook

// src/hooks/usePost.js
import { useState, useEffect } from "react";
import { postsApi } from "@/services/posts";

export function usePost(id) {
    const [post,      setPost]      = useState(null);
    const [isLoading, setIsLoading] = useState(!!id);
    const [error,     setError]     = useState(null);

    useEffect(() => {
        if (!id) return;   // skip if id is null/undefined

        const controller = new AbortController();
        setPost(null);
        setIsLoading(true);
        setError(null);

        postsApi.getById(id)
            .then((data) => { setPost(data); setIsLoading(false); })
            .catch((err) => {
                if (err.name === "CanceledError") return;
                setError(err.response?.data?.detail ?? err.message);
                setIsLoading(false);
            });

        return () => controller.abort();
    }, [id]);

    return { post, isLoading, error };
}

useCurrentUser Hook

// src/hooks/useCurrentUser.js
import { useState, useEffect } from "react";
import { authApi } from "@/services/auth";

export function useCurrentUser() {
    const [user,      setUser]      = useState(null);
    const [isLoading, setIsLoading] = useState(true);
    const [isLoggedIn, setIsLoggedIn] = useState(false);

    useEffect(() => {
        const token = localStorage.getItem("access_token");
        if (!token) {
            setIsLoading(false);
            return;
        }

        authApi.me()
            .then((user) => {
                setUser(user);
                setIsLoggedIn(true);
                setIsLoading(false);
            })
            .catch(() => {
                // Token invalid or expired
                setIsLoading(false);
                setIsLoggedIn(false);
            });
    }, []);

    return { user, isLoading, isLoggedIn };
}

Using Custom Hooks in Components

// src/pages/HomePage.jsx — clean component using custom hooks
import { useState }       from "react";
import { usePosts }       from "@/hooks/usePosts";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import PostList   from "@/components/post/PostList";
import Pagination from "@/components/ui/Pagination";
import TagFilter  from "@/components/post/TagFilter";

export default function HomePage() {
    const [page,   setPage]   = useState(1);
    const [activeTag, setActiveTag] = useState(null);

    const { posts, total, pages, isLoading, error } = usePosts({
        page,
        tag: activeTag,
    });
    const { user } = useCurrentUser();

    return (
        <main className="max-w-3xl mx-auto px-4 py-8">
            <h1 className="text-2xl font-bold mb-6">
                {user ? `Welcome back, ${user.name}!` : "Latest Posts"}
            </h1>
            <TagFilter activeTag={activeTag} onSelect={setActiveTag} />
            <PostList posts={posts} isLoading={isLoading} error={error} />
            {pages > 1 && (
                <Pagination
                    page={page}
                    pages={pages}
                    total={total}
                    onPageChange={setPage}
                />
            )}
        </main>
    );
}
// Component: 30 lines, zero fetch logic, only rendering and state coordination

Common Mistakes

Mistake 1 — Naming custom hooks without the use prefix

❌ Wrong — ESLint and React do not apply hook rules to non-use functions:

function fetchPosts() { useState(...) }   // ESLint won't enforce hook rules!

✅ Correct — always start with use:

function usePosts() { useState(...) }   // ✓ hook rules apply

Mistake 2 — Calling a custom hook conditionally

❌ Wrong — same hook rule violation as conditional useState:

if (isLoggedIn) {
    const { user } = useCurrentUser();   // NEVER inside if!
}

✅ Correct — call unconditionally, handle condition inside the hook.

Quick Reference

Hook Returns Usage
usePosts(params) posts, total, pages, isLoading, error, refetch Post list pages
usePost(id) post, isLoading, error Post detail page
useCurrentUser() user, isLoading, isLoggedIn Auth-dependent UI

🧠 Test Yourself

Two components on the same page both call usePosts({ page: 1 }). How many API requests are made?