Custom hooks
The standard tool for reusing logic. What a good hook's interface looks like, the patterns that come up often, and the cases where you should not extract a hook.
At the end of Chapter 12 we created a small function called useTheme to make Context usage nicer. That useTheme was, in fact, one example of a custom hook. This chapter covers in full what a custom hook is, why you write one, and how.
The type-level interface for custom hooks (generic hooks and so on) is locked down in Chapter 18 (typing hooks) with TypeScript. Getting the basic principles of hooks solid here makes that later chapter read easily.
The problem of sharing logic between components #
Up to now we have reused code at the component (JSX-returning function) level. But what if the thing you want to reuse is not a piece of UI but a piece of logic?
The following two components repeat almost the same logic.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [userId]);
// ... screen rendering ...
}function PostList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/posts')
.then(res => res.json())
.then(data => setPosts(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, []);
// ... screen rendering ...
}The same pattern — data fetching plus loading / error state — is duplicated. Can we collect this in one place and reuse it? A custom hook is the answer.
What a custom hook is #
A custom hook is a regular function whose name starts with use and which uses other hooks inside. That is the entire definition. It is not new syntax; it is just a convention.
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount(prev => prev + 1);
const decrement = () => setCount(prev => prev - 1);
const reset = () => setCount(initial);
return { count, increment, decrement, reset };
}This function is not a component (it does not return JSX). But it does use a hook (useState) inside, which makes it a hook itself.
The calling side:
function Counter() {
const { count, increment, decrement, reset } = useCounter(0);
return (
<div>
<h2>{count}</h2>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
</div>
);
}A single line — useCounter — encapsulates the entire counter logic. Calling the same hook in another component gives that component its own counter.
use” is not just a convention. React decides whether a function is a hook from whether its name starts with use, and applies the hook rules (described below) accordingly. The react-hooks ESLint plugin also enforces this rule. Always start the name with use.The rules of hooks #
Whether you write custom hooks or use them, every hook follows two rules.
Rule 1. Call hooks only at the top of a function #
function App() {
if (someCondition) {
const [count, setCount] = useState(0); // 🚫
}
}Hooks must be called only at the top level of a component function. Not inside conditionals, loops, or nested functions. React identifies which state belongs to which useState by the order of hook calls, so the call order must be the same every render.
The only exception to this rule is React 19’s use() hook. We cover it in Chapter 26 (Suspense and use()).
Rule 2. Call hooks only from React functions #
Hooks may be called only inside a component function or another custom hook. They cannot be called from a regular JavaScript function.
function fetchSomething() {
const [data, setData] = useState(null); // 🚫
}Violating these rules is caught by ESLint, and React throws a runtime error.
Common custom hooks people build #
Here are a few common examples of custom hooks that you write yourself or pull in from a library.
useToggle — toggling a boolean #
import { useState, useCallback } from 'react';
export function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(prev => !prev), []);
return [value, toggle];
}function App() {
const [isOpen, toggleOpen] = useToggle();
return (
<>
<button onClick={toggleOpen}>{isOpen ? 'Close' : 'Open'}</button>
{isOpen && <div>Panel content</div>}
</>
);
}Checkbox toggling, opening and closing modals, expanding and collapsing menus — patterns that come up often, so writing it once pays off.
useLocalStorage — sync state with localStorage #
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
);
}Used almost identically to useState, but the value is automatically stored in localStorage and survives a page refresh. Keeping the interface looking like the underlying hook makes the consuming side intuitive.
useDebounce — delay value changes #
If you fire a search on every keystroke as the user types, you put load on the server. A debounce sends the request only after the user has paused for a moment.
import { useState, useEffect } from 'react';
export function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}function SearchBox() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (!debouncedQuery) return;
fetch(`/api/search?q=${debouncedQuery}`).then(/* ... */);
}, [debouncedQuery]);
return (
<input value={query} onChange={(e) => setQuery(e.target.value)} />
);
}query updates on every keystroke, but debouncedQuery only updates after 500ms of inactivity. As a result, the search request fires only when the user pauses. The key piece is canceling the previous timer in cleanup.
useFetch — fetching data #
Let’s extract the duplicated pattern from the intro into a hook.
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
return res.json();
})
.then(json => {
if (!cancelled) setData(json);
})
.catch(err => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <p>{user.name}</p>;
}
function PostList() {
const { data: posts, loading, error } = useFetch('/api/posts');
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}The duplication from the intro is gone, and each component focuses only on rendering. Even if 100 components share the same logic, fixing the one hook fixes all 100 of them.
In practice, people more often use a library like TanStack Query than build their own useFetch. Those libraries provide the rough edges — caching, revalidation, background updates, pagination — already polished. They are themselves custom hooks built on useEffect + useState, so understanding the underlying mechanism makes learning the libraries faster.
Part 4 of this book (Modern Next.js) goes one step further. In a Server Components environment, data fetching happens directly in the body of the server component function without a client hook like useFetch. Chapter 25 (data fetching and caching) covers it.
When you should not extract a hook #
Custom hooks are a powerful tool, but they are not for every situation. When two or more components really do repeat the same logic is the meaningful moment for extraction.
1. A trivial useState wrapper #
function useName() {
return useState('');
}
// usage
const [name, setName] = useName();Trading the one line useState('') for the one line useName() only adds abstraction cost. The caller now has to look one level deeper to know what they get. A one-liner is better left inline.
2. Logic used in only one place #
Pre-extracting a hook “in case a second component needs it some day” is also abstraction debt. Extract when a second component actually needs the same logic. Until then, inline inside the component is clearer.
3. UI fragments #
If you want to reuse a piece of UI, extract a component, not a hook. A hook returns values / functions, not JSX.
What a good hook’s interface looks like #
Looking at the hooks people build over and over, their interface tends to be one of two patterns.
- Tuple (array) — when you want it to feel like
useState.const [value, setValue] = useToggle() - Object — when the return has three or more values, or when you want to name them explicitly.
const { data, loading, error } = useFetch(url)
Two return values usually means a tuple; three or more usually means an object. Returning an object lets the caller pick only what they need and lets you add new fields without breaking existing call sites.
The real value of custom hooks #
The most striking thing about writing custom hooks is the freedom of abstraction. What we extracted is not just a plain function — it is a stateful unit of behavior. Counter, toggle, data fetching, debounce — “features” can now live separated from any specific component, as independent units.
Another important point is that each component instance that calls the hook gets its own state. If two components both call useCounter(), two separate counts exist independently. In other words, hooks share code but do not share state. If you do need two components to look at the same state, that is Chapter 11 (lifting state up) or Chapter 12 (Context) territory.
Try it yourself #
Let’s tidy up the components we have built into custom hooks.
src/hooks/useToggle.js:
import { useState, useCallback } from 'react';
export function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(prev => !prev), []);
return [value, toggle];
}src/hooks/useLocalStorage.js:
import { useState, useEffect } from 'react';
export function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}src/App.jsx:
import { useToggle } from './hooks/useToggle';
import { useLocalStorage } from './hooks/useLocalStorage';
function App() {
const [isMenuOpen, toggleMenu] = useToggle();
const [name, setName] = useLocalStorage('userName', '');
return (
<div style={{ padding: '16px' }}>
<h1>Custom hooks demo</h1>
<section style={{ marginTop: '16px' }}>
<button onClick={toggleMenu}>{isMenuOpen ? 'Close menu' : 'Open menu'}</button>
{isMenuOpen && (
<ul>
<li>Home</li>
<li>About</li>
<li>Contact</li>
</ul>
)}
</section>
<section style={{ marginTop: '16px' }}>
<p>Type your name (it survives a refresh):</p>
<input value={name} onChange={(e) => setName(e.target.value)} />
{name && <p>Hello, {name}!</p>}
</section>
</div>
);
}
export default App;The toggle menu opens and closes on each click, and the name input is preserved across refreshes. The component code is much shorter, and both the toggle and the localStorage-sync logic are ready to be used elsewhere.
Exercises #
- Build the
useDebouncehook yourself and apply it to a search box. Verify in the console log thatqueryupdates immediately on every keystroke whiledebouncedQueryonly updates after 500ms of inactivity. Then experiment with removing the cleanup that cancels the previous timer and see what happens. - Finding
useFetch’s weakness. TheuseFetchabove fires a separate request even when multiple components call it with the same URL (no caching). Build a screen where five components call/api/users/1simultaneously and observe the five requests in the network tab. TanStack Query exists to solve exactly this problem (this book does not cover it directly). - Identifying cases where you should not extract a hook. Pick which of the following three is a fit for extraction. (a) The single line
useState(''). (b) State synced withlocalStorage(useLocalStorage). (c) A 30-line form validation routine used in only one component. Only (b) is a fit. Explain why in one paragraph.
In one line: a custom hook is a function whose name starts with
useand which uses other hooks. The rules of hooks: call at the top of a function, call only inside a component or another hook. Common patterns areuseToggle,useLocalStorage,useDebounce,useFetch. Hooks share logic but do not share state. Don’t extract one-line or one-place-only logic — leave it inline.
Next chapter #
So far the focus has been on “how to make it work”. The next chapter, Chapter 14 Performance optimization, covers the tools for “how to make it run fast” — memo, useMemo, useCallback. We also note how React 19’s React Compiler changes these tools’ role.