Mastering React Custom Hooks: Logic Extraction and Reusability
1. Custom Hooks are Not Just "Copy-Paste"
A common misconception among React developers is that Custom Hooks are just a way to shorten component code.
"My component is 500 lines long. I'll move 200 lines to a generic named hook."
This often results in a "Closet Hook"—you shove the messy code into a closet and close the door. The mess is still there; you just can't see it until you open the file.
A true Custom Hook is an abstraction. It allows you to extract stateful logic, separate concerns, and reuse behavior across different components.
If your hook relies heavily on the specific context of one component and cannot be used anywhere else, it might be better left inside the component (co-location) or refactored into a sub-component instead.
2. The Headless UI Pattern
One of the most powerful applications of Custom Hooks is the Headless UI pattern. This pattern separates the logic of a UI element (state, event handlers, accessibility) from its visual representation (JSX, CSS).
Think about a Generic Modal.
- Logic: Open/Close state, toggle function, trapping focus, closing on
Escape key.
- UI: The white box, the shadow, the "X" button, the content.
If you hardcode the logic into a <Modal> component, it's hard to change the design later without breaking the logic.
Instead, extract the logic:
function useModal() {
const [isOpen, setIsOpen] = useState(false);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
const toggle = () => setIsOpen(prev => !prev);
// Close on Escape key
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, []);
return { isOpen, open, close, toggle };
}
Now you can build any kind of modal (a slide-over, a dialog, a tooltip) using this same hook. The hook handles the behavior; you handle the pixels. Libraries like Radix UI and React Aria rely heavily on this pattern.
3. The Rules of Hooks Architecture
When creating a reusable hook, follow the SOLID principles, specifically the Single Responsibility Principle.
Avoid "God Hooks"
Don't create a usePageData hook that fetches data, handles form state, manages modal visibility, and tracks analytics. That's a kitchen sink.
Instead, compose small hooks:
useUserQuery() (Data fetching)
useForm() (Form state)
useDisclosure() (Modal state)
// Composition over Monolith
function UserProfilePage() {
const { data: user } = useUserQuery(id);
const { isOpen, open, close } = useDisclosure();
const form = useForm({ defaultValues: user });
// ...
}
This makes testing easier. You can unit test useDisclosure in isolation without mocking API calls.
4. Goodbye useEffect for Data Fetching
In the early days of Hooks (2019), everyone wrote manual useEffect + fetch logic.
In 2025, this is considered an anti-pattern for production apps.
Why?
- Race Conditions: Request A starts. User clicks another button. Request B starts. Request A finishes after B and overwrites the state with old data.
- Memory Leaks: Setting state on an unmounted component.
- No Caching: Every time you navigate, it re-fetches.
Use dedicated libraries like TanStack Query (React Query) or SWR.
Your Custom Hooks should wrap these libraries to provide domain-specific context.
// Good abstraction
export function useProducts(categoryId: string) {
return useQuery({
queryKey: ['products', categoryId],
queryFn: () => api.getProducts(categoryId),
// Centralize configuration here
staleTime: 5 * 60 * 1000,
retry: 1
});
}
Now components just call useProducts(id). They don't know it uses React Query, Axios, or fetch. You can swap the implementation later without touching the UI.
5. Testing Custom Hooks
One of the biggest benefits of extracting logic into hooks is testability. You don't need to render a complex DOM to test logic.
Use React Hooks Testing Library (now part of @testing-library/react).
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
test('should increment counter', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
If your logic is inside a component, you have to click buttons and find DOM elements to test it. If it's in a hook, you just call functions and assert values. Pure bliss.
6. Compound Components Pattern
When you have a hook that manages complex UI state (like useTab), consider pairing it with Compound Components.
Instead of passing 20 props to a single <Tabs /> component:
// Bad: Prop drilling hell
<Tabs items={items} activeIndex={idx} onChange={setIdx} theme="dark" ... />
Use Context API inside your hook or component system:
// Good: Declarative and Flexible
<Tabs.Root>
<Tabs.List>
<Tabs.Trigger value="account">Account</Tabs.Trigger>
<Tabs.Trigger value="password">Password</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="account">...</Tabs.Content>
</Tabs.Root>
This is how modern libraries (Radix, Shadcn) are built. The hook (useTabs) manages the state, and the Context provides that state to the sub-components.
7. Common Pitfalls: Stale Closures & Infinite Loops
The most treacherous part of Custom Hooks is the Dependency Array.
If you omit a variable from the dependency array because "I don't want it to re-run," you often introduce a Stale Closure bug. The hook will "remember" an old version of the variable from a previous render.
Example:
useEffect(() => {
console.log(count); // Keeps printing '0' even if count increments
}, []); // Empty dependency array tells React "Never re-run this"
Also, be careful with referential equality.
If your hook returns a NEW object or array every time it runs:
// Bad
return { data: [1, 2, 3] }; // New array reference on every render
Any component using this hook will re-render every single time, even if data hasn't changed.
Use useMemo to stabilize the return value:
// Good
return useMemo(() => ({ data }), [data]);
8. When to Use useLayoutEffect
99% of the time, you want useEffect. It runs asynchronously after the paint, so it doesn't block the UI.
However, if you need to measure DOM elements (width, height) and update state synchronously before the user sees the flicker:
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
setHeight(rect.height);
}, []);
If you use useEffect here, the user might see the element jump (Layout Shift). useLayoutEffect guarantees the update happens before the screen is painted.
9. Debugging with useDebugValue
React DevTools shows hooks as "State" or "Effect" by default, which is confusing if you have many hooks.
Use useDebugValue to provide a readable label.
function useOnlineStatus() {
const isOnline = ...;
useDebugValue(isOnline ? 'Online' : 'Offline');
return isOnline;
}
Now in DevTools, you see OnlineStatus: "Online" instead of State: true.
10. Summary: When to Hook?
Ask yourself these questions before creating a Custom Hook:
- Is this logic used in more than one place? (Rule of Three)
- Is the component file getting too large? (Separation of Concerns)
- Is the logic complex enough to warrant a unit test? (Testability)
- Does it involve lifecycle methods (
useEffect)? (Abstraction)
If the answer is yes, hook it up.
Custom Hooks are the primitives of React development. Mastering them is the step that takes you from a "React User" to a "React Architect."