I spent an afternoon debugging a form in a Next.js application where field validation ran three times on every keystroke. The component file looked pristine—about twenty lines of JSX. But underneath, it imported six custom hooks: useInput, useToggle, useValidation, useFormSubmit, useHover, and usePrevious. Every simple operation was buried under layers of helper functions. Tracking down which hook held the stale closure took two hours.
That is indirection, not abstraction. Indirection moves code somewhere else without reducing mental overhead. Abstraction hides complexity behind a simple, reliable interface. If your custom hook just wraps useState and returns a getter and setter, delete it and put the state back in the component.
The False Economy of Pointless Hooks
Consider the classic useToggle hook. Developers write this to avoid writing setIsOpen(prev => !prev) inside a button handler.
// Indirection: saves zero mental overhead
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(v => !v), []);
return [value, toggle];
}This saves four characters of code at the cost of a new file, an import statement, and another stack frame in your React DevTools tree. It hides nothing about how state works, prevents no bugs, and gives the reader zero useful context. If anything, it forces developers to open a second tab just to verify that toggle actually uses functional updates under the hood.
A custom hook is worth extracting when it meets at least one of these criteria:
It coordinates non-React browser APIs (observers, event listeners, sockets) with React lifecycle mechanics.
It manages complex state transitions where isolated updates would allow invalid state combinations.
It solves asynchronous race conditions and cleanup logic that developers regularly mess up.
Pattern 1: Encapsulating Browser APIs and Cleanup
Browser APIs like ResizeObserver, IntersectionObserver, or window event listeners require careful lifecycle management in React 19. You must bind them on mount, tear them down on unmount, and handle Server-Side Rendering (SSR) in Next.js 16 without trigger-happy hydration mismatches.
Here is a hook that tracks element dimensions. It hides the imperative setup, handles missing browser globals during SSR, and guarantees cleanup when the element unmounts or changes.
import { useState, useLayoutEffect, useEffect, useRef } from 'react';
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
export function useElementBounds() {
const ref = useRef(null);
const [bounds, setBounds] = useState({ width: 0, height: 0 });
useIsomorphicLayoutEffect(() => {
const node = ref.current;
if (!node) return;
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
const { width, height } = entry.contentRect;
setBounds(prev => {
if (prev.width === width && prev.height === height) return prev;
return { width, height };
});
});
observer.observe(node);
return () => {
observer.disconnect();
};
}, []);
return [ref, bounds];
}This is a true abstraction. The consuming component doesn't care about ResizeObserver instances, SSR safety checks, or reference equality checks on size boundaries. It attaches the ref to an HTML element and receives pixel dimensions. The hook absorbs all the edge cases.
Pattern 2: Managing Async Race Conditions with AbortController
Data fetching inside client components is full of bugs. The most common is the race condition: a user types into an autocomplete field, fire-and-forget requests go out over the network, and request #1 resolves after request #2, leaving stale data on screen.
A custom hook that handles request cancellation via native AbortController and manages pending states isolates network mechanics from UI layout.
import { useState, useEffect, useRef } from 'react';
export function useDebouncedSearch(query, delayMs = 300) {
const [state, setState] = useState({ data: null, error: null, isLoading: false });
const lastQuery = useRef(query);
useEffect(() => {
if (!query.trim()) {
setState({ data: null, error: null, isLoading: false });
return;
}
const controller = new AbortController();
const timer = setTimeout(() => {
setState(prev => ({ ...prev, isLoading: true }));
fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
})
.then(res => {
if (!res.ok) throw new Error(`HTTP status ${res.status}`);
return res.json();
})
.then(data => {
setState({ data, error: null, isLoading: false });
})
.catch(err => {
if (err.name === 'AbortError') return;
setState({ data: null, error: err.message, isLoading: false });
});
}, delayMs);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query, delayMs]);
return state;
}Writing this logic directly in a component cluttering your JSX means someone will eventually delete the cleanup return statement or forget the AbortController during a refactor. Encapsulating it guarantees that stale requests get cancelled automatically when the search input updates rapidly.
The Performance Penalty of Returning Inline Objects
A major gotcha with custom hooks is returning freshly created objects or arrays on every single render. If your hook returns an object literal and the consumer passes that object down into a memoized component, you break memoization immediately.
// BAD: Creates a new object reference every render
function useUserPermissions(user) {
const canEdit = user.role === 'admin' || user.isOwner;
const canDelete = user.role === 'admin';
return { canEdit, canDelete }; // New object every time!
}If a component calls const { canEdit } = useUserPermissions(user), every state change in that parent forces the hook to return a brand-new object reference. If you pass those permissions into child components, they will re-render even when values haven't changed. Return primitive values when possible, or wrap return values in useMemo if the computation or object allocation is expensive.
How to Decide Before Creating a Hook
Ask yourself these three questions before creating a new hook file in your project:
Does it manage at least two coupled pieces of state or a native browser API? If it wraps a single
useStatecall without complex logic, leave it in the component.Can I name it after what it produces rather than how it works? A good hook provides a domain abstraction (e.g.,
useDebouncedSearch,useElementBounds) rather than exposing implementation details (e.g.,useSetStateAndRunEffect).Will this simplify the consumer's mental model? If reading the consuming component requires jumping between three hook files to understand a single click handler, you've added indirection. Put the code back where it runs.










