Skip to content

Custom Hooks Worth Writing

Aman Kumar Singh8 min read
Part 7 of 50From the React & Next.js Complete Guide series
Custom Hooks Worth Writing — article by Aman Kumar Singh

In the previous article, we covered useRef and the handful of situations where reaching into the DOM directly is the right call instead of a workaround. That article, like this one, is part of the React & Next.js Complete Guide series, where we build up from first principles toward production Next.js apps.

Custom hooks are where a lot of teams either build real leverage or build a mess. Extracting logic into a hook is trivial. Extracting the right logic, at the right boundary, with a clean return contract, is a skill most engineers pick up by getting it wrong a few times first. This article is about the difference between a hook that pays for itself and one that just moves complexity somewhere less visible.

I want to be upfront about the bias in this series: defer complexity until it's earned. That applies here too. Not every repeated useEffect needs a hook. The goal is to recognize when one is genuinely worth writing, not to rack up hooks for their own sake.

What actually justifies a custom hook

A custom hook is just a function that calls other hooks and follows the use naming convention. There's no framework magic beyond that. Which means the bar for creating one should be the same bar you'd apply to extracting any other function: does this eliminate real duplication, or does it hide behavior that would be clearer inline?

Three signals tell me a custom hook is worth writing:

  • The same stateful logic appears in more than one component, and it's genuinely the same logic, not just superficially similar code that happens to look alike today.
  • The logic has enough internal complexity (subscriptions, cleanup, coordinating multiple pieces of state) that inlining it clutters the component and obscures what the component is actually rendering.
  • The logic needs to be testable independent of any specific component. A hook can be tested with @testing-library/react's renderHook without mounting a full UI tree, which is often the real reason to extract it.

Here's a case that meets all three: tracking whether a component is still mounted before setting state after an async operation completes. This shows up constantly in components that fetch data and hasn't disappeared even with modern data-fetching patterns, because plenty of async code still lives outside a library like React Query.

import { useCallback, useEffect, useRef } from "react";

export function useIsMounted() {
  const isMountedRef = useRef(false);

  useEffect(() => {
    isMountedRef.current = true;
    return () => {
      isMountedRef.current = false;
    };
  }, []);

  return useCallback(() => isMountedRef.current, []);
}

Used inside a component:

function OrderStatus({ orderId }: { orderId: string }) {
  const [status, setStatus] = useState<string | null>(null);
  const isMounted = useIsMounted();

  useEffect(() => {
    let cancelled = false;

    fetchOrderStatus(orderId).then((result) => {
      if (!cancelled && isMounted()) {
        setStatus(result);
      }
    });

    return () => {
      cancelled = true;
    };
  }, [orderId, isMounted]);

  return <span>{status ?? "Loading…"}</span>;
}

Notice the hook does exactly one thing. It answers a single question: is this component still on screen? Nothing about fetching or request lifecycle lives inside it. That narrowness is what makes it reusable across a fetch hook, a websocket hook, and a timer hook without any of them needing to know about each other.

Design the return value like a public API

The most common mistake I see in custom hooks is treating the return value as an afterthought. A hook's return signature is an API contract for every component that consumes it, and it deserves the same care you'd give a function signature in a shared library.

Two shapes dominate in practice: an object, or a tuple (mirroring useState). Pick based on how many values you return and whether order matters to the caller.

import { useCallback, useEffect, useState } from "react";

type FetchStatus = "idle" | "loading" | "success" | "error";

type UseFetchResult<T> = {
  data: T | null;
  status: FetchStatus;
  error: Error | null;
  refetch: () => void;
};

export function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [status, setStatus] = useState<FetchStatus>("idle");
  const [error, setError] = useState<Error | null>(null);
  const [version, setVersion] = useState(0);

  const refetch = useCallback(() => setVersion((v) => v + 1), []);

  useEffect(() => {
    let cancelled = false;
    setStatus("loading");
    setError(null);

    fetch(url)
      .then((res) => {
        if (!res.ok) throw new Error(`Request failed: ${res.status}`);
        return res.json() as Promise<T>;
      })
      .then((json) => {
        if (!cancelled) {
          setData(json);
          setStatus("success");
        }
      })
      .catch((err: Error) => {
        if (!cancelled) {
          setError(err);
          setStatus("error");
        }
      });

    return () => {
      cancelled = true;
    };
  }, [url, version]);

  return { data, status, error, refetch };
}

I return an object here, named properties, because there are four values and the caller shouldn't need to remember positional order to destructure the one they need. Compare this to useState, which returns a tuple because there are exactly two values with a well-understood convention (value first, setter second) that every React developer already has memorized.

Get this contract wrong early and you'll be renaming properties across a dozen call sites later. Spend the five extra minutes designing the shape before writing the implementation. It's cheap insurance.

Composition over configuration

Custom hooks tend to accumulate options over time. Someone needs a small variation, so a boolean flag gets added. Then another variation, another flag. Eventually you have a hook with eight parameters and a truth table nobody wants to reason about.

The better instinct, most of the time, is composing smaller hooks rather than parameterizing one large hook.

import { useEffect, useRef, useState } from "react";

export function useDebouncedValue<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}

export function useSearchResults(query: string) {
  const debouncedQuery = useDebouncedValue(query, 300);
  return useFetch<SearchResult[]>(`/api/search?q=${encodeURIComponent(debouncedQuery)}`);
}

useDebouncedValue knows nothing about search, and useSearchResults knows nothing about debouncing internals. It simply composes a general-purpose hook with a fetch hook. If a different feature needs debounced input for a form validation call, useDebouncedValue is already there, unmodified, unaware search ever existed.

This mirrors a principle worth applying broadly across a codebase: small, single-purpose units that compose cleanly age better than large configurable units that try to anticipate every future requirement. A configuration option you added for one caller becomes a liability the moment a second caller needs a slightly different variation of that same option.

Common pitfalls that show up in production

A few mistakes recur often enough to call out directly.

Returning new object or array references on every render. If a hook returns { data, refetch } and either value's identity changes on every render regardless of whether the underlying data changed, any useEffect or useMemo in a consuming component that depends on the hook's return value will re-run needlessly. Wrap functions in useCallback and, where cheap, memoize derived objects with useMemo.

Swallowing the dependency array problem instead of solving it. Wrapping messy effect logic in a custom hook doesn't fix a missing dependency. It just moves the bug one file over. Run eslint-plugin-react-hooks against custom hooks the same way you would against components, and don't suppress exhaustive-deps warnings without understanding exactly why the dependency is safe to omit.

Hooks that do cleanup work but forget the unmount case for concurrent calls. In the useFetch example above, a cancelled flag guards against stale updates from a request that resolves after the URL changes or the component unmounts. Skipping that guard is how "why did this component flash with old data" bugs get introduced. They're miserable to reproduce because they depend on network timing.

Overusing useEffect inside a custom hook when the value could be computed during render. If a hook can derive its result synchronously from props and state, do that instead of pushing it through an effect and a second state variable. Reserve effects for actual side effects: subscriptions, timers, external system synchronization.

Testing the hook by testing every component that uses it. If a hook is complex enough to warrant extraction, it's complex enough to warrant its own test suite using renderHook. Testing it in isolation catches regressions faster and doesn't require re-mounting an entire component tree for every edge case.

When not to extract a hook

Not every repeated pattern needs to become a hook. If two components each call useState and a single useEffect with three lines of straightforward logic, extracting that "duplication" into a hook can cost more in indirection than it saves in lines. A reader now has to jump to another file to understand what a component does, for a saving of maybe five lines.

I'd rather see a small amount of duplicated, obvious code than a hook whose name doesn't clearly communicate what it does and whose implementation requires opening a second file to understand a component that seemed simple at the call site. Extraction should make the codebase easier to reason about. Line count is a secondary concern at best.

Key takeaways

  • Extract a custom hook when logic is genuinely duplicated, complex enough to clutter a component, or needs to be tested independently of any specific UI.
  • Design a hook's return value (object vs. tuple) as deliberately as you'd design a public function signature; it's an API contract other engineers will rely on.
  • Prefer composing several small, single-purpose hooks over building one large hook with many configuration flags.
  • Guard against stale updates after unmount or after inputs change mid-flight; a `cancelled` flag or an `isMounted` check is cheap insurance against hard-to-reproduce bugs.
  • Memoize functions and derived values returned from a hook so consumers don't re-run effects or memoized computations unnecessarily.
  • Don't extract a hook just to reduce line count; extract it when it genuinely improves clarity or testability.

Frequently asked questions

What's the difference between a custom hook and a regular utility function?

A custom hook calls other React hooks internally (`useState`, `useEffect`, `useRef`, or another custom hook) and therefore participates in React's render and lifecycle rules. A regular utility function doesn't call hooks and can be used anywhere in plain TypeScript, including outside components. If a function doesn't need `useState` or `useEffect`, it shouldn't be a hook; keep it a plain function so it's usable and testable without a React renderer.

Do custom hooks need to start with the word "use"?

Yes, and this isn't just a style convention. React's linter (`eslint-plugin-react-hooks`) and the Rules of Hooks enforcement rely on the `use` prefix to know a function is a hook and should be checked for correct hook usage (no conditional calls, no calls inside loops). Skipping the prefix means those checks silently stop applying to your function.

Can a custom hook call another custom hook?

Yes, and this is one of the most useful patterns available. Composing hooks (as shown with `useDebouncedValue` feeding into `useSearchResults`) is generally preferable to writing one large hook that tries to handle every variation through configuration options.

How do I test a custom hook without mounting a full component?

Use `renderHook` from `@testing-library/react`. It mounts a minimal test component internally so the hook can call other hooks, then exposes the hook's return value and a way to trigger re-renders, without you needing to write and maintain a throwaway UI component just to exercise the hook.

Should every `useEffect` with cleanup logic become a custom hook?

No. If the effect is a few lines and appears once, inlining it in the component is fine and often more readable. Extract it once the same logic needs to live in a second place, or once the effect's internal complexity (subscriptions, multiple state variables, cleanup ordering) starts to dominate the component's render function and obscure what the component actually renders.

Is it safe to return a new object literal from a custom hook on every render?

It's safe in the sense that it won't cause bugs by itself, but it can cause unnecessary re-renders or effect re-runs in components that depend on that object's identity, for example inside a `useEffect` dependency array or a `React.memo` comparison. Memoize the returned object with `useMemo` and memoize any returned functions with `useCallback` when the hook is likely to be used inside dependency arrays elsewhere.

Related articles

Avoiding Unnecessary Re-Renders — Aman Kumar Singh
useMemo and useCallback, When They Help — Aman Kumar Singh
useEffect Without the Footguns — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.