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,
};
}
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.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.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 |