Good custom React hooks extract behaviour, not just code. They own their own effects and cleanup, declare honest dependencies, and return a small, predictable API — so a component can use them without knowing how they work.
When to Write a Custom Hook
A custom hook earns its place when logic is stateful and reused, or when a single component is carrying too much wiring. If you are only sharing a pure calculation with no state or effects, that is a plain function, not a hook.
- The same useEffect setup/teardown appears in more than one component.
- A component mixes unrelated concerns — data, subscriptions, and UI — in one body.
- You are subscribing to something external: the DOM, the network, storage, or a timer.
- You want to unit-test a behaviour in isolation from any component.
useInView: IntersectionObserver as a Hook
This is the hook I use most. It wraps IntersectionObserver and tells a component whether its element is on screen — the basis for scroll animations, lazy loading, and analytics. The key detail is cleanup: the observer must disconnect when the component unmounts.
import { useEffect, useRef, useState } from 'react';
export function useInView(options) {
const ref = useRef(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node) return;
const observer = new IntersectionObserver(([entry]) => {
setInView(entry.isIntersecting);
}, options);
observer.observe(node);
return () => observer.disconnect(); // cleanup on unmount
}, [options]);
return [ref, inView];
}Note the dependency array. Passing an inline options object would create a new reference every render and re-run the effect endlessly. Either memoize the options with useMemo in the caller, or pass primitive options (threshold, rootMargin) and build the object inside the hook.
useScrollReveal: Build Hooks on Hooks
The best sign a hook has the right shape is that you can compose it. useScrollReveal is just useInView plus a one-way latch, so an element animates in once and stays visible instead of flickering as the user scrolls back and forth.
import { useEffect, useState } from 'react';
import { useInView } from './useInView';
export function useScrollReveal(options) {
const [ref, inView] = useInView(options);
const [revealed, setRevealed] = useState(false);
useEffect(() => {
if (inView) setRevealed(true); // latch: never flip back to false
}, [inView]);
return [ref, revealed];
}useMediaQuery: Subscribe and Clean Up
Reacting to breakpoints in JS (not just CSS) is common for conditional rendering. This hook shows the subscribe-then-unsubscribe pattern that every event-based hook should follow.
import { useEffect, useState } from 'react';
export function useMediaQuery(query) {
const [matches, setMatches] = useState(
() => window.matchMedia(query).matches,
);
useEffect(() => {
const mql = window.matchMedia(query);
const onChange = (e) => setMatches(e.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, [query]);
return matches;
}Patterns Worth Keeping
| Pattern | Why it matters |
|---|---|
| Always clean up | Observers, listeners, and timers leak and double-fire without a return function |
| Honest dependencies | Missing deps go stale; unstable deps re-run forever |
| Lazy initial state | Pass a function to useState so expensive reads run once |
| Small return shape | A tuple or one object keeps the call site clean and predictable |
| One responsibility | A hook that does one thing is easy to compose and test |
Final Take
Custom hooks are not about cleverness — they are about drawing a clean line around a behaviour so the rest of your code can ignore the details. Get cleanup and dependencies right, keep the API small, and the same handful of hooks will follow you from project to project.