I4 · Part 2 · Intermediate
Custom hooks
Sharing stateful logic between components — not sharing the state itself.
08On this page
A custom hook is just a function
There is no API for this. A custom hook is a function whose name starts with
use and which calls other hooks. That naming is not decoration:
it is how the linter knows to apply the rules of hooks to it.
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return [on, toggle];
}
const [isOpen, toggleOpen] = useToggle();
Each call gets its own state
Two components calling useToggle() do not share a value. A
custom hook shares logic, never state — the hooks inside run
against whichever component is calling. If you want shared state, that is
context or a store.
What to return
return [value, setValue]; // tuple — when the caller renames both
return { data, error, isLoading }; // object — when there are several, or optional
Tuples for two values that callers will rename, the way
useState does. Objects once there are three or more, so the
caller destructures only what it needs and adding a field later is not a
breaking change.
Three that earn their keep
Reading and writing localStorage
function useLocalStorage(key, initial) {
const [value, setValue] = useState(() => {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : initial;
} catch {
return initial; // private mode, quota, bad JSON
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {}
}, [key, value]);
return [value, setValue];
}
Both accesses are wrapped. localStorage throws in private
browsing on some browsers, when the quota is full, and when a page has storage
blocked — an unguarded read takes the whole component down.
A media query
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mql = window.matchMedia(query);
const onChange = (e) => setMatches(e.matches);
setMatches(mql.matches); // in case it changed before subscribing
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, [query]);
return matches;
}
In a server-rendered app the lazy initialiser would crash — there is no
window. That case is what
useSyncExternalStore exists
for; it takes a separate server snapshot.
Debouncing a value
function useDebounced(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
const query = useDebounced(input); // fetch on this, render on input
Extracting one from a component
The signal is not length. It is a group of hooks that always move together and that another component would want whole.
// before: this block appears in three components
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => { ... }, [userId]);
// after
const { user, loading, error } = useUser(userId);
The test for a good extraction: the component that uses it reads better, and
the hook makes sense with a name that does not mention the component it came
from. useDashboardTopSection is a function you moved;
useUser is an abstraction.
Composing hooks
function useSearch(endpoint) {
const [input, setInput] = useState("");
const query = useDebounced(input, 300); // hooks calling hooks
const { data, loading } = useFetch(endpoint + "?q=" + query);
return { input, setInput, results: data ?? [], loading };
}
This is where custom hooks pay off. Each piece is testable and replaceable on its own, and the composition reads like a description of the feature.
useDebugValue
function useOnlineStatus() {
const isOnline = useSyncExternalStore(subscribe, getSnapshot);
useDebugValue(isOnline ? "Online" : "Offline"); // label in DevTools
return isOnline;
}
Purely a development affordance: React DevTools shows
OnlineStatus: "Online" next to the hook instead of a bare
true. It is stripped from production builds.
Worth it for a hook used across a codebase or shipped in a library, where somebody debugging will meet it without having read it. Not worth it for a hook used in one component. If formatting the value is expensive, pass a function as the second argument — it only runs when DevTools is open.
Mistakes worth avoiding
| Mistake | Why it hurts |
|---|---|
| Calling a hook conditionally inside your hook | Same rule, same corruption — call order must be stable |
| A hook that takes ten options and does five jobs | Nobody can use half of it. Split it. |
| Returning a new object without memoising | Callers putting it in a dependency array get an effect on every render |
Naming it without use | The lint rules stop applying, silently |
Wrapping a single useState | useName() that returns useState("") adds a file and no meaning |
The dependency question
function useFetch(url) {
const [data, setData] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(url).then((r) => r.json()).then((d) => { if (!cancelled) setData(d); });
return () => { cancelled = true; };
}, [url]); // a string — safe to depend on
return data;
}
Take primitives as arguments where you can. A hook whose parameter is an options object re-runs whenever the caller renders, because the caller almost certainly wrote the object inline — and now the fix lives in every call site instead of in the hook.
Interview answer, one sentence
"A custom hook is a function starting with use that calls other
hooks, and it shares logic rather than state — each caller gets its own.
Extract one when a group of hooks always changes together and another
component would want the whole group."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series