Should we not write custom hooks?
Creating custom hooks is one of the strongest ways to abstract repeated logic in React.
But if you use useEffect in the wrong places, you’ll quickly run into infinite loops, memory leaks, or data inconsistencies. 😊
In this post, we’ll cover how to manage side effects in custom hooks, the most common pitfalls, and solid patterns you can rely on.
Quick refresher: What is a side effect?
A “side effect” is any operation that happens outside of a component’s pure render. In other words, anything that breaks the pure-function model:- Fetching data (
fetch) - Adding event listeners
- Manipulating the DOM or storage
- Timers or subscriptions
useEffect / useLayoutEffect. When you build a custom hook, those hooks are your main bridge to the outside world.A simple custom hook example
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth)
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth)
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return width
}
This hook tracks the window width.- Side effect:
addEventListener→ talks to the outside world. - Cleanup:
removeEventListener→ prevents leaks.
Common Mistake #1: Dependency chaos
If your custom hook usesuseEffect, the dependency array is critical.useEffect(() => {
fetch(`/api/data?query=${query}`)
}, []) // ❌ missing `query` dependency!
In this case, changing query won’t trigger a new fetch—it runs only once.Add the dependency:
useEffect(() => {
fetch(`/api/data?query=${query}`)
}, [query])
Now it refetches whenever
query changes.💡 Tip: Don’t disable ESLint’s react-hooks/exhaustive-deps rule. It saves lives.
Common Mistake #2: Missing cleanup
If a custom hook starts a timer, subscription, or event listener, it must return a cleanup.function useTimer() {
useEffect(() => {
const id = setInterval(() => console.log('tick'), 1000)
return () => clearInterval(id)
}, [])
}
Without clearInterval, the timer keeps running even after unmount. Result: memory leaks. 💥Common Mistake #3: Mixing state and effects in one blob
Some developers put both state and side-effect work into a singleuseEffect—this hurts readability.useEffect(() => {
setData(fetchData()) // ❌ both state and effect in one go
}, [])
Correct:useEffect(() => {
fetchData().then(setData)
}, [])
Or, cleaner still: split the concern into its own hook.const data = useDataFetcher('/api/posts')
This boosts testability and reusability.Patterns for useEffect inside custom hooks
1) Data fetching
function useFetch(url) {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let ignore = false
setLoading(true)
fetch(url)
.then(res => res.json())
.then(json => { if (!ignore) setData(json) })
.catch(setError)
.finally(() => setLoading(false))
return () => { ignore = true }
}, [url])
return { data, error, loading }
}
The “ignore flag” pattern avoids the classic warning: "Can't perform a React state update on an unmounted component." ✅> Alternative: use AbortController to cancel requests in flight.
2) Event listener management
function useKeyPress(targetKey) {
const [pressed, setPressed] = useState(false)
useEffect(() => {
const down = (e) => e.key === targetKey && setPressed(true)
const up = (e) => e.key === targetKey && setPressed(false)
window.addEventListener('keydown', down)
window.addEventListener('keyup', up)
return () => {
window.removeEventListener('keydown', down)
window.removeEventListener('keyup', up)
}
}, [targetKey])
return pressed
}
Reactive, safe, and tidy—a small work of art in hook land. ✅Best practices for side-effect management
- Always return a cleanup. Every effect leaves a trace; remove it yourself.
- Get the dependency array right. Deterministic dependencies → deterministic behavior.
- Cancel async work. Prefer
AbortControllerfor fetches. - Split effects. One
useEffect≠ many responsibilities. - Make hooks testable. Keep DOM access out; test with jest / RTL.
Reactive pattern: Effect + Memo combo
function useFilteredList(list, query) {
const filtered = useMemo(() => list.filter(i => i.includes(query)), [list, query])
useEffect(() => {
console.log('List updated')
}, [filtered])
return filtered
}
Here useMemo optimizes the calculation, while useEffect runs only when the result changes. Performance + correctness, hand in hand. 🤝Debugging tips
- In React DevTools → Components, watch your hook values.
- Log how many times effects fire.
- Use the Profiler to detect unnecessary renders.
useEffect(() => {
console.count('Effect fired')
})
If a page renders 5 times, you’ll know why.Conclusion
Custom hooks simplify your code, but pooruseEffect hygiene multiplies complexity.In short:
- Side effect = work outside render.
- Every effect needs a cleanup.
- Dependencies are life-saving.
- Custom hooks should be isolated and testable.