Skip to content
React rc patterns 5 min read

Custom Hook Patterns

A custom hook is just a function whose name starts with use and that calls other hooks. That tiny rule unlocks the most powerful reuse mechanism in modern React: you can extract stateful logic — fetching, subscriptions, timers, toggles — into a plain function and share it across components without touching their JSX. Where higher-order components and render props wrapped or nested the tree to share behavior, custom hooks share the behavior directly and leave the tree untouched. This page covers the patterns you will reuse most: data hooks, behavior hooks, and composing hooks together.

Why hooks beat HOCs and render props

The older logic-sharing patterns both pay a structural tax. A higher-order component wraps your component in another component, which means an extra layer in the tree, opaque prop injection, and prop-name collisions when you stack several. Render props avoid the wrapping but introduce nesting — three or four shared behaviors and you are deep in “wrapper hell,” with logic and markup tangled in the same render callback.

Custom hooks have neither problem. They return values, so the consuming component reads them like any other variable and renders normally. The logic lives in one testable function, and composing two hooks is just calling both.

ConcernHOCRender propsCustom hook
Adds tree depthYes (wrapper)Yes (nesting)No
How logic is sharedInjected propsFunction childReturn value
Composing severalProp collisionsDeep nestingPlain function calls
Where types flowAwkwardAwkwardNatural

Tip: A hook can only be called at the top level of a component or another hook — never inside loops, conditions, or callbacks. The leading use prefix is what lets the linter enforce that rule.

Data hooks

The most common custom hook wraps an async resource. A useFetch hook centralizes loading, error, and data state so every screen does not re-implement the same useEffect dance. Crucially, it must abort stale requests to avoid race conditions when the URL changes.

import { useState, useEffect } from "react";

function useFetch(url) {
  const [state, setState] = useState({
    data: null,
    error: null,
    loading: true,
  });

  useEffect(() => {
    const controller = new AbortController();
    setState({ data: null, error: null, loading: true });

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => setState({ data, error: null, loading: false }))
      .catch((error) => {
        if (error.name !== "AbortError") {
          setState({ data: null, error, loading: false });
        }
      });

    return () => controller.abort();
  }, [url]);

  return state;
}

export default function UserCard({ id }) {
  const { data, error, loading } = useFetch(
    `https://jsonplaceholder.typicode.com/users/${id}`
  );

  if (loading) return <p>Loading…</p>;
  if (error) return <p>Failed: {error.message}</p>;
  return <h2>{data.name}</h2>;
}

The component knows nothing about fetch, AbortController, or effect cleanup — it just reads three values. Swap in a useQuery wrapper from TanStack Query later and the call site barely changes, because the hook owns the contract.

Behavior hooks

Not every hook fetches. Many encapsulate a small piece of interactive state with a clean, named API. These are the workhorses of a design system.

A useToggle hook turns boolean flips into intent-revealing functions:

import { useState, useCallback } from "react";

function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  const setTrue = useCallback(() => setOn(true), []);
  const setFalse = useCallback(() => setOn(false), []);
  return { on, toggle, setTrue, setFalse };
}

useDisclosure is the same idea framed for modals and drawers, where open/close/toggle read more naturally than a raw setter. A useMediaQuery hook subscribes to a CSS breakpoint and re-renders when it changes:

import { useState, useEffect } from "react";

function useMediaQuery(query) {
  const [matches, setMatches] = useState(
    () => window.matchMedia(query).matches
  );

  useEffect(() => {
    const mql = window.matchMedia(query);
    const onChange = (e) => setMatches(e.matches);
    mql.addEventListener("change", onChange);
    setMatches(mql.matches);
    return () => mql.removeEventListener("change", onChange);
  }, [query]);

  return matches;
}

function Navbar() {
  const isMobile = useMediaQuery("(max-width: 768px)");
  return <nav>{isMobile ? <MenuButton /> : <FullMenu />}</nav>;
}

Each hook does one thing and returns plain values, so any component can adopt it without restructuring.

Composing hooks

Because hooks are functions, building a bigger hook from smaller ones is just calling them. A useSearch hook can combine a debounced input with a data hook:

import { useState, useEffect } from "react";

function useDebounced(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

function useSearch(term) {
  const debounced = useDebounced(term, 400);
  const { data, loading } = useFetch(
    `https://api.example.com/search?q=${encodeURIComponent(debounced)}`
  );
  return { results: data ?? [], loading };
}

export function SearchBox() {
  const [term, setTerm] = useState("");
  const { results, loading } = useSearch(term);

  return (
    <div>
      <input value={term} onChange={(e) => setTerm(e.target.value)} />
      {loading ? <span>Searching…</span> : <span>{results.length} hits</span>}
    </div>
  );
}

Output:

Typing "rea" then "react" fires only one request after the user pauses,
because useDebounced gates the value before useFetch ever sees it.

Each hook stays independently testable, yet useSearch reads like a single feature. This layering is exactly what made render props feel heavy — and what custom hooks make trivial.

Best practices

  • Name hooks with the use prefix so the rules-of-hooks linter can verify call order.
  • Return a stable shape — an object for many values, a tuple for a clear [value, setValue] pair — and keep it consistent across renders.
  • Wrap returned functions in useCallback when consumers will pass them to memoized children or effect dependency arrays.
  • Always clean up subscriptions, timers, and in-flight requests in the effect’s return function to prevent leaks and race conditions.
  • Keep each hook focused; if one hook fetches, debounces, and formats, split it so the parts compose.
  • Don’t hide too much — a hook that secretly reads context or routing makes call sites hard to reason about; take such inputs as arguments where practical.
Last updated June 14, 2026
Was this helpful?