React Hooks Deep Dive

React Hooks Deep Dive

Mykola Nesterchuk - March 6, 2026

#react
#hooks

Introduction

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.

1. useEffect - The Most Misunderstood Hook

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 }); }, []);

2. useLayoutEffect - When Timing Matters

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 to useLayoutEffect only 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;

3. useReducer - State Machines Done Right

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: dispatch from useReducer is stable across renders - you don't need to wrap it in useCallback.

4. useRef - More Than DOM Access

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.current inside JSX won't trigger a re-render when it changes. If you need the UI to update, use useState instead.

5. Writing Custom Hooks

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.

6. Rules of Hooks - Why They Exist

React tracks hooks by call order. That's why the rules matter:

  • Only call hooks at the top level - never inside conditions, loops, or nested functions
  • Only call hooks from React functions - components or custom hooks
// ❌ 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.

Summary

Hooks are simple to start with and deep to master. Here's the quick reference:

  • Side effects after paintuseEffect with proper deps and cleanup
  • DOM reads before paintuseLayoutEffect
  • Complex state transitionsuseReducer
  • Mutable values without re-rendersuseRef
  • Reusable stateful logic → custom hooks
  • Avoiding violationseslint-plugin-react-hooks

Table of Contents