Chapter 6: Custom Hooks
React's real superpower isn't components โ it's the ability to extract and share stateful logic without changing your component tree. Custom hooks are how you build your own toolkit.
๐ Where we are: TaskFlow has components (Ch 3), state management (Ch 2), effects with localStorage persistence (Ch 4), and Context for theme + global task state (Ch 5). But the logic is scattered โ
useStateanduseEffectcalls are copy-pasted across components. Time to clean house.
๐ง Conceptsโ
1. What Are Custom Hooks?โ
A custom hook is just a function that starts with use and calls other hooks inside. That's it. No magic API, no registration, no special syntax.
// This is a custom hook
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return width;
}
Why do they matter?
Without custom hooks, you'd copy-paste the same useState + useEffect patterns across components. Custom hooks let you:
- Extract logic โ pull complex state/effect combos out of components
- Reuse across components โ share the same logic without shared state
- Test independently โ test the logic without rendering a component
- Name your intentions โ
useLocalStoragesays more than 10 lines of useState+useEffect
Critical mental model: Each component that calls a custom hook gets its own independent copy of that hook's state. Hooks share logic, not state.
const ComponentA = () => {
const width = useWindowWidth(); // ComponentA's own width state
}
const ComponentB = () => {
const width = useWindowWidth(); // ComponentB's own SEPARATE width state
}
Both components track window width, but they each have their own useState internally. If you need shared state, that's what Context is for (Chapter 5).
2. Rules of Hooksโ
These aren't guidelines โ they're hard rules that React depends on. Break them and things will break.
Rule 1: Only call hooks at the top levelโ
// โ WRONG โ inside a condition
const SearchResults = ({ query }) => {
if (query === "") {
return <p>Type something...</p>;
}
// React calls hooks by ORDER. If the early return
// sometimes fires, hook order changes โ crash
const [results, setResults] = useState([]);
// ...
}
// โ
RIGHT โ hooks before any returns
const SearchResults = ({ query }) => {
const [results, setResults] = useState([]);
if (query === "") {
return <p>Type something...</p>;
}
// ...
}
Why? React identifies hooks by their call order in each render. If you put a hook inside a condition, the order changes between renders, and React pairs the wrong state with the wrong hook.
// Render 1: condition true
useState(0) โ hook #1
useEffect(...) โ hook #2
// Render 2: condition false, hook inside if is skipped
useEffect(...) โ hook #1 โ React thinks this is useState! ๐ฅ
Rule 2: Only call hooks from React functionsโ
Hooks can only be called from:
- React function components
- Other custom hooks
NOT from regular JavaScript functions, classes, event handlers, or async callbacks.
// โ WRONG โ regular function
function fetchData() {
const [data, setData] = useState(null); // ๐ฅ
}
// โ
RIGHT โ custom hook
function useFetchData() {
const [data, setData] = useState(null);
// ...
return data;
}
Rule 3: The use prefix is mandatoryโ
React uses the use prefix to identify hooks and apply its rules. If you name a function getWindowWidth instead of useWindowWidth, React won't check it for rule violations.
// โ React won't enforce hook rules
function getLocalStorage(key) {
const [value, setValue] = useState(...); // Works but no rule checking
}
// โ
React knows this is a hook
function useLocalStorage(key) {
const [value, setValue] = useState(...); // Rules enforced
}
๐ React 19 + React Compiler: The upcoming React Compiler (experimental in React 19) relies even more heavily on these rules. The compiler analyzes your hooks to auto-optimize, so violating the rules won't just cause bugs โ it'll prevent optimizations.
3. Patterns for Custom Hooksโ
Pattern 1: Wrapping a Browser APIโ
The most common pattern โ wrap a browser API in a reactive way.
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const goOnline = () => setIsOnline(true);
const goOffline = () => setIsOnline(false);
window.addEventListener("online", goOnline);
window.addEventListener("offline", goOffline);
return () => {
window.removeEventListener("online", goOnline);
window.removeEventListener("offline", goOffline);
};
}, []);
return isOnline;
}
// Usage
const StatusBar = () => {
const isOnline = useOnlineStatus();
return <span>{isOnline ? "๐ข Online" : "๐ด Offline"}</span>;
}
Pattern 2: Abstracting localStorageโ
Persistent state that survives page reloads.
function useLocalStorage<T>(key: string, initialValue: T) {
// Lazy initialization โ only reads localStorage once
const [value, setValue] = useState<T>(() => {
try {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
// Sync to localStorage whenever value changes
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// localStorage might be full or disabled
}
}, [key, value]);
return [value, setValue] as const;
}
// Usage โ works exactly like useState, but persists
const Settings = () => {
const [theme, setTheme] = useLocalStorage("theme", "light");
const [fontSize, setFontSize] = useLocalStorage("fontSize", 16);
// Values survive page reloads!
}
๐ก Vercel Tip: Notice the lazy state initialization โ
useState(() => ...). Without the function form,localStorage.getItemwould run on every render even though the value is only used once.
Pattern 3: Domain Logic Hookโ
Encapsulate your app's business logic.
interface Task {
id: string;
title: string;
completed: boolean;
createdAt: Date;
}
function useTasks() {
const [tasks, setTasks] = useLocalStorage<Task[]>("tasks", []);
const addTask = (title: string) => {
setTasks((prev) => [
...prev,
{
id: crypto.randomUUID(),
title,
completed: false,
createdAt: new Date(),
},
]);
};
const toggleTask = (id: string) => {
setTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
};
const deleteTask = (id: string) => {
setTasks((prev) => prev.filter((t) => t.id !== id));
};
const completedCount = tasks.filter((t) => t.completed).length;
const totalCount = tasks.length;
return {
tasks,
addTask,
toggleTask,
deleteTask,
completedCount,
totalCount,
};
}
๐ก Vercel Tip: We use functional
setTasks(prev => ...)everywhere. This means the callbacks don't depend ontasksin their closure โ they always use the latest state. Stable references, no stale closures.
Pattern 4: Composing Hooksโ
Custom hooks can call other custom hooks!
function useFilteredTasks() {
const { tasks, ...actions } = useTasks();
const [filter, setFilter] = useState<"all" | "active" | "completed">("all");
const filteredTasks = (() => {
switch (filter) {
case "active":
return tasks.filter((t) => !t.completed);
case "completed":
return tasks.filter((t) => t.completed);
default:
return tasks;
}
})();
return {
tasks: filteredTasks,
allTasks: tasks,
filter,
setFilter,
...actions,
};
}
Notice how useFilteredTasks builds on useTasks, which builds on useLocalStorage, which uses useState + useEffect. Hooks compose like LEGO.
Pattern 5: Debounced Valueโ
Useful for search inputs โ don't fire on every keystroke.
function useDebouncedValue<T>(value: T, delayMs: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delayMs);
return () => clearTimeout(timer); // Cleanup: cancel if value changes again
}, [value, delayMs]);
return debouncedValue;
}
// Usage
const TaskSearch = () => {
const [query, setQuery] = useState("");
const debouncedQuery = useDebouncedValue(query, 300);
// Only fires API call when user stops typing for 300ms
useEffect(() => {
if (debouncedQuery) {
searchTasks(debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
4. When to Extract a Custom Hookโ
Not every useState needs to be a hook. Extract when:
| Extract when... | Example |
|---|---|
| Logic is reused across 2+ components | useLocalStorage, useWindowWidth |
| A component has too many hooks tangled together | 5+ useState/useEffect in one component |
| The logic has a clear name/concept | useTasks, useAuth, useDebounce |
| You want to test the logic independently | Business logic separate from UI |
Don't extract when:
- The logic is only used once and is simple (2-3 lines)
- You're just trying to make a component "look clean" โ sometimes inline is clearer
- The abstraction doesn't have a good name (if you can't name it, it's not a real concept)
5. Return Value Patternsโ
Hooks can return anything. Choose based on usage:
// Single value โ simplest
function useOnlineStatus(): boolean { ... }
const isOnline = useOnlineStatus();
// Tuple โ like useState (value + setter pair)
function useLocalStorage<T>(key: string, init: T): [T, (v: T) => void] { ... }
const [theme, setTheme] = useLocalStorage("theme", "light");
// Object โ when returning many things (most common for domain hooks)
function useTasks(): { tasks: Task[]; addTask: (t: string) => void; ... } { ... }
const { tasks, addTask, deleteTask } = useTasks();
Rule of thumb: Tuple for 2 values (like useState), object for 3+.
6. Custom Hooks vs. Utility Functionsโ
Not everything needs to be a hook!
// This does NOT need to be a hook โ no React state or effects
function formatDate(date: Date): string {
return date.toLocaleDateString("en-GB", {
day: "numeric", month: "short", year: "numeric"
});
}
// This NEEDS to be a hook โ uses useState and useEffect
function useCurrentTime(intervalMs: number = 1000) {
const [time, setTime] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => setTime(new Date()), intervalMs);
return () => clearInterval(timer);
}, [intervalMs]);
return time;
}
If your function doesn't call any hooks, it's just a utility function. Don't put use in front of it.
๐ก Examplesโ
Example 1: useMediaQueryโ
A hook that reacts to CSS media query changes:
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() =>
window.matchMedia(query).matches
);
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
// Usage
const Layout = () => {
const isMobile = useMediaQuery("(max-width: 768px)");
const prefersDark = useMediaQuery("(prefers-color-scheme: dark)");
return (
<div className={prefersDark ? "dark" : "light"}>
{isMobile ? <MobileNav /> : <DesktopSidebar />}
</div>
);
}
๐ก Vercel Tip: This subscribes to a derived boolean (matches/doesn't match) rather than a continuous value (pixel width). The component only re-renders when the boolean flips, not on every pixel of resize.
Example 2: usePreviousโ
Track the previous value of any state โ useful for animations and comparisons:
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
});
return ref.current;
}
// Usage
const Counter = () => {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>Current: {count}, Previous: {prevCount ?? "none"}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}
Example 3: useToggleโ
Simple but useful โ saves writing the same pattern everywhere:
function useToggle(initial: boolean = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(v => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse } as const;
}
// Usage
const TaskItem = ({ task }: { task: Task }) => {
const { value: isEditing, setTrue: startEdit, setFalse: stopEdit } = useToggle();
return isEditing
? <TaskEditForm task={task} onDone={stopEdit} />
: <TaskCard task={task} onEdit={startEdit} />;
}
๐จ Project Task: Refactor TaskFlow with Custom Hooksโ
Time to clean up TaskFlow! Extract the tangled logic from your components into clean, reusable hooks.
Step 1: Create useLocalStorageโ
Create src/hooks/useLocalStorage.ts:
import { useState, useEffect } from "react";
export function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item !== null ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// Silently fail (quota exceeded, private browsing, etc.)
}
}, [key, value]);
return [value, setValue] as const;
}
Step 2: Create useTasksโ
Create src/hooks/useTasks.ts:
import { useLocalStorage } from "./useLocalStorage";
import type { Task } from "../types";
export function useTasks() {
const [tasks, setTasks] = useLocalStorage<Task[]>("taskflow-tasks", []);
const addTask = (title: string) => {
setTasks((prev) => [
...prev,
{
id: crypto.randomUUID(),
title,
completed: false,
createdAt: new Date().toISOString(),
},
]);
};
const toggleTask = (id: string) => {
setTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
};
const deleteTask = (id: string) => {
setTasks((prev) => prev.filter((t) => t.id !== id));
};
const updateTask = (id: string, updates: Partial<Omit<Task, "id">>) => {
setTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, ...updates } : t))
);
};
return { tasks, addTask, toggleTask, deleteTask, updateTask };
}
Step 3: Create useFilteredTasksโ
Create src/hooks/useFilteredTasks.ts:
import { useState, useMemo } from "react";
import { useTasks } from "./useTasks";
export type FilterStatus = "all" | "active" | "completed";
export function useFilteredTasks() {
const taskActions = useTasks();
const [filter, setFilter] = useState<FilterStatus>("all");
const filteredTasks = useMemo(() => {
switch (filter) {
case "active":
return taskActions.tasks.filter((t) => !t.completed);
case "completed":
return taskActions.tasks.filter((t) => t.completed);
default:
return taskActions.tasks;
}
}, [taskActions.tasks, filter]);
const counts = useMemo(
() => ({
total: taskActions.tasks.length,
active: taskActions.tasks.filter((t) => !t.completed).length,
completed: taskActions.tasks.filter((t) => t.completed).length,
}),
[taskActions.tasks]
);
return {
...taskActions,
filteredTasks,
filter,
setFilter,
counts,
};
}
Step 4: Create useThemeโ
Create src/hooks/useTheme.ts:
import { useLocalStorage } from "./useLocalStorage";
import { useEffect } from "react";
type Theme = "light" | "dark";
export function useTheme() {
const [theme, setTheme] = useLocalStorage<Theme>("taskflow-theme", "light");
useEffect(() => {
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return { theme, setTheme, toggleTheme };
}
Step 5: Refactor Your Componentsโ
Now update your main App component. Before:
// โ Before โ everything tangled in the component
const App = () => {
const [tasks, setTasks] = useState(() => {
const stored = localStorage.getItem("tasks");
return stored ? JSON.parse(stored) : [];
});
const [filter, setFilter] = useState("all");
const [theme, setTheme] = useState("light");
useEffect(() => {
localStorage.setItem("tasks", JSON.stringify(tasks));
}, [tasks]);
useEffect(() => {
document.documentElement.classList.toggle("dark", theme === "dark");
}, [theme]);
const addTask = (title) => { /* ... 8 lines ... */ };
const deleteTask = (id) => { /* ... */ };
const toggleTask = (id) => { /* ... */ };
const filteredTasks = tasks.filter(/* ... */);
// 50 lines of logic before any JSX!
}
After:
// โ
After โ clean, readable, logic extracted
const App = () => {
const { filteredTasks, filter, setFilter, addTask, toggleTask, deleteTask, counts } =
useFilteredTasks();
const { theme, toggleTheme } = useTheme();
return (
<div className={`app ${theme}`}>
<Header
theme={theme}
onToggleTheme={toggleTheme}
taskCount={counts.total}
/>
<TaskFilter filter={filter} onFilterChange={setFilter} counts={counts} />
<TaskForm onAdd={addTask} />
<TaskList
tasks={filteredTasks}
onToggle={toggleTask}
onDelete={deleteTask}
/>
</div>
);
}
Step 6: Organize Your Hooksโ
Your hooks folder should look like:
src/
hooks/
useLocalStorage.ts โ generic, reusable anywhere
useTasks.ts โ domain-specific (TaskFlow business logic)
useFilteredTasks.ts โ domain-specific (builds on useTasks)
useTheme.ts โ generic, reusable
index.ts โ barrel export
Create src/hooks/index.ts:
export { useLocalStorage } from "./useLocalStorage";
export { useTasks } from "./useTasks";
export { useFilteredTasks } from "./useFilteredTasks";
export { useTheme } from "./useTheme";
Acceptance Criteriaโ
You're done when:
- All task CRUD logic lives in
useTasks, not in components - Filtering logic lives in
useFilteredTasks - Theme logic lives in
useTheme - localStorage persistence lives in
useLocalStorage - No component has more than ~2 hook calls (aside from the root)
- Everything still works exactly as before
- You could reuse
useLocalStoragein any other project
๐งช Challenge: Build useAsyncActionโ
Create a hook that wraps any async function with loading/error states:
function useAsyncAction<T>(asyncFn: () => Promise<T>) {
// Your implementation here
// Returns: { execute, data, isLoading, error }
}
// Usage:
const TaskList = () => {
const { execute: loadTasks, data: tasks, isLoading, error } =
useAsyncAction(() => fetch("/api/tasks").then(r => r.json()));
useEffect(() => { loadTasks(); }, []);
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return <ul>{tasks?.map(t => <TaskCard key={t.id} task={t} />)}</ul>;
}
Hints:
- You need
useStatefordata,isLoading, anderror - Wrap the async call in a try/catch
- Use
useCallbackto stabilize the execute function - Consider: what happens if the component unmounts during the async call?
๐ Further Readingโ
- React docs: Reusing Logic with Custom Hooks
- React docs: Rules of Hooks
- usehooks.com โ collection of useful custom hooks with source code
- React docs: You Might Not Need an Effect โ helps you decide what should be a hook vs a plain function
Next up: Chapter 7 โ React Router โ
We'll add pages to TaskFlow โ dashboard, task detail, and settings โ with client-side routing.