Writing Custom React Hooks That Don't Turn Into an Unmaintainable Mess

By James Nguyen Updated September 24, 2026
Writing Custom React Hooks That Don't Turn Into an Unmaintainable Mess

My early custom hooks solved an immediate, visible problem and quietly created three new ones underneath, tangled dependency arrays and stale closures I didn't fully understand until they broke in production in ways that took real debugging time to trace back to the hook itself.

Starting With the Actual Problem, Not the Abstraction

I now write the logic directly inline in a component first, and only extract it into a custom hook once I've genuinely needed the same logic in a second place, rather than pre-emptively abstracting on the assumption I'll need reuse later, a habit that used to produce hooks built around imagined future requirements rather than real, proven ones.

The Stale Closure Problem That Bit Me Repeatedly

A custom hook capturing a value from an outer scope inside a callback, without that value in the dependency array, silently uses a stale, outdated version of that value on subsequent renders, a bug that's genuinely hard to spot in code review and that I've now been burned by enough times to check for deliberately every time.

// buggy: count is captured once and never updates inside the interval
function useCounter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // always logs the initial value
    }, 1000);
    return () => clearInterval(id);
  }, []); // missing count in the dependency array
  return count;
}

The Fix: Functional Updates and Correct Dependencies

Using the functional form of setState, or correctly including every genuinely used value in the dependency array, rather than suppressing the exhaustive-deps lint warning to make it go away, fixed this category of bug consistently once I stopped treating the lint rule as an obstacle and started treating it as a genuine signal.

function useCounter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      setCount((c) => c + 1); // functional update avoids the stale closure
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return count;
}

Returning a Stable Interface

Early hooks I wrote returned a new object literal on every render, causing any component destructuring that return value to re-render unnecessarily whenever a parent re-rendered, even if the actual underlying values hadn't changed. Memoizing the returned object, or returning a plain array like useState does, avoided this.

function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue((v) => !v), []);
  return [value, toggle] as const; // stable array reference pattern
}

Cleanup Functions Aren't Optional for Anything With a Subscription

Any hook that sets up a subscription, an event listener, a WebSocket connection, a timer, needs a cleanup function returned from useEffect, and skipping this created a genuine memory leak in a dashboard component that mounted and unmounted frequently as users navigated between views.

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);
  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize); // cleanup
  }, []);
  return width;
}

Testing Custom Hooks in Isolation

Using the React Testing Library's renderHook utility to test a hook's behavior directly, without needing to mount it inside a full component just to exercise its logic, made writing genuine test coverage for hook edge cases considerably less awkward than my earlier approach of testing hooks indirectly through whatever component happened to use them.

import { renderHook, act } from "@testing-library/react";

test("useToggle flips the boolean value", () => {
  const { result } = renderHook(() => useToggle(false));
  act(() => result.current[1]());
  expect(result.current[0]).toBe(true);
});

Naming Conventions That Actually Communicate Intent

Prefixing every custom hook with use, following React's own convention, isn't just a linting requirement, it lets the rules-of-hooks ESLint plugin correctly analyze dependency arrays and hook call rules, something that silently breaks if you name a hook-like function without that prefix.

Knowing When Not to Extract a Hook At All

For logic used in exactly one place with no realistic near-term reuse, I've stopped extracting a hook purely for the sake of "clean code," since an extra layer of indirection for genuinely single-use logic has cost me more debugging time tracing through an extra abstraction than it ever saved in code organization.

Daniel Justin

About the Author

James Nguyen is a full-stack programmer with more than ten years of experience engineering software systems. Specializing in the Node.js and Python ecosystems, he focuses on backend architecture, API design, and clean data integration. Follow me on YouTube and Instagram.

More Articles