A custom hook is a function whose name begins with use and that calls other hooks.
That is the entire specification. There is no createHook, no registration, no API — you
write a function, and the naming convention is what makes it a hook.
The smallest useful one
Every context in this app ships with a hook next to it. Here is useCart:
export function useCart(): CartContextValue {
const context = useContext(CartContext);
if (context === undefined) {
throw new Error('useCart must be used inside a <CartProvider>');
}
return context;
}Four lines, and it does three jobs:
- It hides the context object. Nothing outside
CartContext.tsximportsCartContext, so how the cart is implemented stays private. Replace context with something else and no component changes. - It narrows the type. The context is
CartContextValue | undefined; the hook returnsCartContextValue. Every consumer is spared a null check. - It gives one place for the guard. Forget the provider and you get a sentence telling you so, rather than "cannot read properties of undefined" from somewhere unrelated.
The result is that consuming a context looks like nothing at all:
const { totals } = useCart();
const { user, isAuthenticated, isAdmin, logout } = useAuth();
const { crusts, toppings } = useMenu();
const { showToast } = useToast();Write this wrapper every time you create a context. It is the highest-value four lines in the codebase.
Extracting stateful logic
The real use of custom hooks is sharing logic that involves state or effects — something a plain function cannot do. Suppose two components both need to know whether the viewport is narrow:
// src/hooks/useMediaQuery.ts
import { useEffect, useState } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const list = window.matchMedia(query);
const onChange = (e: MediaQueryListEvent) => setMatches(e.matches);
setMatches(list.matches); // in case it changed before the listener attached
list.addEventListener('change', onChange);
return () => list.removeEventListener('change', onChange);
}, [query]);
return matches;
}And then, in any component:
const isNarrow = useMediaQuery('(max-width: 768px)');
return isNarrow ? <CartDrawer /> : <CartSidebar />;The listener, the cleanup and the initial read are written once. A component that uses it does not
have to know matchMedia exists.
Hooks share logic, never state
This is the part that trips people up. Two components calling useMediaQuery get
two independent pieces of state. The hook is a recipe, not a store.
function Navbar() {
const isNarrow = useMediaQuery('(max-width: 768px)'); // its own useState
}
function Footer() {
const isNarrow = useMediaQuery('(max-width: 768px)'); // a different useState
}They happen to agree because they are watching the same thing, not because they are connected. If
you need components to share the actual value — one cart, not one cart each — that is
context. Custom hooks and context solve different problems and are
usually used together, which is exactly what useCart is.
The rules apply
A custom hook is subject to the same two rules as a built-in one: call it at the top level, and
only from a component or another hook. Which is precisely why the use prefix is not
optional — it is how the linter knows to enforce those rules, and how a reader knows this function
is not callable from an event handler.
// This is NOT a hook, and naming it useFormatMoney would be actively misleading.
export function formatMoney(amount: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
}If a function calls no hooks, it is a plain function. Leave it as one — plain functions can be
called anywhere, which is more useful, and formatMoney is called from event handlers
and render alike.
Returning an object or a tuple
Return an array when the caller will want to rename the values — that is why
useState does. Return an object when there are several and the names are the point:
// Tuple: the caller names both. Right for a two-value pair.
const [isOpen, setIsOpen] = useDisclosure();
// Object: named fields, destructure what you need. Right for a richer API.
const { products, pizzas, drinks, toppings, crusts, loading, error, reload } = useMenu();The object form has a practical edge as the hook grows: adding a field breaks nothing, whereas adding a third element to a tuple is a change every caller has to think about.
A fetching hook
The most common thing people extract. Wrapping the loading/error/data triple once removes a great deal of repetition:
// src/hooks/useFetch.ts
export function useFetch<T>(path: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
api
.get<T>(path, { signal: controller.signal })
.then(setData)
.catch((err) => {
// An abort is not a failure — it means we navigated away, or StrictMode re-ran the effect.
if (!controller.signal.aborted) {
setError(err instanceof Error ? err.message : 'Request failed');
}
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [path]);
return { data, loading, error };
}Note what came along for free: every caller now gets the AbortController cleanup
correct, because it is written once. That is the strongest argument for extracting a hook — not fewer
lines, but the tricky part being right everywhere.
This is also the point where you should ask whether to keep going. Caching, deduplication, retries and revalidation are all things you will want next, and all things TanStack Query already does. Writing it by hand once is worth it to understand what the library is doing; writing it twice is usually a mistake.
When to extract
When the same stateful logic appears in a second component. Not before — a hook extracted from one call site is usually shaped wrong, because you have only seen one set of requirements.
The exception is the context wrapper, which is worth writing immediately. It is not really about reuse; it is about the guard and the type.
Next
Redux, and Whether You Need It — how all of this compares to the library everyone has heard of.