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.
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.
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;
}
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;
}
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
}
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;
}
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);
});
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.
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.