React – Custom Hooks

July 18, 20265 min readUpdated 8/18/2026

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.tsx imports CartContext, 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 returns CartContextValue. 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.