
Mykola Nesterchuk - March 6, 2026
Hooks changed how we write React. They replaced lifecycle methods, made logic reusable, and pushed us toward functional components entirely. But with that power comes a set of subtle traps - stale closures, infinite loops, layout flickers - that can quietly break your app.
This article goes beyond the basics and covers the real-world patterns and pitfalls you'll run into.
useEffect runs after the browser has painted. That makes it the right place for data fetching, subscriptions, and side effects that don't need to block rendering.
useEffect(() => { fetchUser(userId).then(setUser); }, [userId]);
The dependency array controls when the effect re-runs. Missing a dependency means your effect works with stale data. Including too many causes unnecessary re-runs.
Pitfall: Stale Closures
// ❌ count is always 0 inside the interval useEffect(() => { const id = setInterval(() => { console.log(count); }, 1000); return () => clearInterval(id); }, []); // ✅ use a ref or include count in deps useEffect(() => { const id = setInterval(() => { console.log(count); }, 1000); return () => clearInterval(id); }, [count]);
Always clean up subscriptions, timers, and event listeners in the return function - otherwise you'll leak memory and trigger state updates on unmounted components.
Pitfall: Infinite Loop
// ❌ object is recreated every render → effect loops forever const options = { page: 1 }; useEffect(() => { fetchData(options); }, [options]); // ✅ move stable values inside the effect or memoize them useEffect(() => { fetchData({ page: 1 }); }, []);
useLayoutEffect fires synchronously after DOM mutations but before the browser paints. Use it when you need to read layout or make DOM changes that would cause a visible flicker if deferred.
useLayoutEffect(() => { const rect = ref.current.getBoundingClientRect(); setTooltipPosition({ top: rect.bottom, left: rect.left }); }, []);
If you use useEffect here instead, the tooltip might render in the wrong position for one frame - a visible flash.
Rule of thumb: Start with
useEffect. Switch touseLayoutEffectonly if you notice a layout flicker.
Note that useLayoutEffect doesn't run on the server, which causes a mismatch warning in SSR apps. For SSR-safe code, guard it:
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
When state logic grows complex - multiple sub-values, transitions that depend on the current state - useReducer is cleaner than stacking useState calls.
const initialState = { status: 'idle', data: null, error: null }; function reducer(state, action) { switch (action.type) { case 'FETCH_START': return { ...state, status: 'loading' }; case 'FETCH_SUCCESS': return { status: 'success', data: action.payload, error: null }; case 'FETCH_ERROR': return { status: 'error', data: null, error: action.payload }; default: return state; } } function UserProfile({ userId }) { const [state, dispatch] = useReducer(reducer, initialState); useEffect(() => { dispatch({ type: 'FETCH_START' }); fetchUser(userId) .then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data })) .catch(err => dispatch({ type: 'FETCH_ERROR', payload: err.message })); }, [userId]); if (state.status === 'loading') return <Spinner />; if (state.status === 'error') return <ErrorMessage message={state.error} />; return <Profile data={state.data} />; }
The reducer is a pure function - easy to test in isolation and reason about. All state transitions are explicit and centralized.
Optimization tip:
dispatchfromuseReduceris stable across renders - you don't need to wrap it inuseCallback.
Everyone knows useRef for accessing DOM nodes. But its real power is storing any mutable value that persists across renders without triggering a re-render.
// Tracking previous value function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }, [value]); return ref.current; } function Counter() { const [count, setCount] = useState(0); const prevCount = usePrevious(count); return ( <p>Now: {count}, Before: {prevCount}</p> ); }
Another common pattern - storing a timer ID or subscription so it survives re-renders:
const timerRef = useRef(null); const start = () => { timerRef.current = setInterval(tick, 1000); }; const stop = () => { clearInterval(timerRef.current); };
Pitfall: Reading
ref.currentinside JSX won't trigger a re-render when it changes. If you need the UI to update, useuseStateinstead.
Custom hooks are just functions that start with use and call other hooks. They're the right way to extract and share stateful logic.
function useFetch(url) { const [state, dispatch] = useReducer(reducer, initialState); useEffect(() => { if (!url) return; let cancelled = false; dispatch({ type: 'FETCH_START' }); fetch(url) .then(res => res.json()) .then(data => { if (!cancelled) dispatch({ type: 'FETCH_SUCCESS', payload: data }); }) .catch(err => { if (!cancelled) dispatch({ type: 'FETCH_ERROR', payload: err.message }); }); return () => { cancelled = true; }; }, [url]); return state; }
Notice the cancelled flag - it prevents setting state on an unmounted component when the URL changes before the previous fetch finishes.
React tracks hooks by call order. That's why the rules matter:
// ❌ Breaks hook order on re-renders if (isLoggedIn) { const [profile, setProfile] = useState(null); } // ✅ Always called unconditionally const [profile, setProfile] = useState(null);
The ESLint plugin eslint-plugin-react-hooks catches these violations automatically - add it to your project if you haven't already.
Hooks are simple to start with and deep to master. Here's the quick reference:
useEffect with proper deps and cleanupuseLayoutEffectuseReduceruseRefeslint-plugin-react-hooks