React Hooks Explained: useState, useEffect, useCallback, useMemo, useRef In Depth

React Hooks Explained: useState, useEffect, useCallback, useMemo, useRef In Depth

React hooks look simple until they surprise you in production. useState batching in React 18 changes when re-renders fire. useEffect with wrong dependencies causes infinite loops or stale closures. useCallback and useMemo are misused more than any other hooks. This guide covers every hook in depth — the rules, the traps, and the patterns that work reliably.

TL;DR: useState for local UI state. useEffect for side effects with cleanup. useCallback for stable function references (only when passed to memoized children). useMemo for expensive calculations. useRef for mutable values that don’t trigger re-renders. Custom hooks to share stateful logic.

useState — batching and functional updates

import { useState } from 'react';

// React 18: automatic batching — multiple setState = ONE re-render
function Counter() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');

  const handleClick = () => {
    setCount(c => c + 1); // Functional update: safe when next state depends on previous
    setName('updated');
    // React 18: ONE re-render (batched), not two
  };
}

// Functional update pattern — avoids stale closure bugs
const [count, setCount] = useState(0);
// WRONG: setCount(count + 1) — count may be stale in closures
// RIGHT: setCount(c => c + 1) — c is always current

// Object state: always spread to preserve other fields
const [form, setForm] = useState({ name: '', email: '' });
const handleName = (e) => setForm(prev => ({ ...prev, name: e.target.value }));

// Lazy initialization: for expensive initial computation
const [data, setData] = useState(() => expensiveCompute()); // Called once

useEffect — dependencies, cleanup, and timing

import { useEffect, useState } from 'react';

// The dependency array:
// [] — run once on mount (and cleanup on unmount)
// [dep1, dep2] — run when dep1 or dep2 changes
// (omitted) — run after every render (usually wrong)

// Cleanup — ALWAYS clean up subscriptions, timers, event listeners
function Timer() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setCount(c => c + 1), 1000);
    return () => clearInterval(id); // Cleanup: runs before next effect OR unmount
  }, []); // Empty deps: set up once, clean up on unmount
}

// Data fetching with cleanup (prevent setState on unmounted component)
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  useEffect(() => {
    let cancelled = false;
    fetchUser(userId).then(data => {
      if (!cancelled) setUser(data); // Only set if still mounted
    });
    return () => { cancelled = true; };
  }, [userId]); // Re-run when userId changes
}

// Stale closure bug — classic mistake
function Bad() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // STALE: count is always 0 (closed over initial value)
    }, 1000);
    return () => clearInterval(id);
  }, []); // Missing count in deps!
}
// Fix: add count to deps, or use functional update

useCallback and useMemo — when they actually help

// useCallback: stable function reference
// ONLY helps when the function is passed to a React.memo component
function Parent() {
  const [count, setCount] = useState(0);

  // Without useCallback: new function reference every render
  // MemoizedChild would re-render even though it shouldn't
  const handleDelete = useCallback((id) => {
    deleteItem(id);
  }, []); // No deps: function never changes

  return ;
}
// Rule: useCallback is ONLY useful when the function is a prop to a memoized child

// useMemo: cache expensive computation
function ProductList({ products, query, sort }) {
  const filtered = useMemo(() => {
    return products
      .filter(p => p.name.includes(query))
      .sort((a, b) => a[sort] > b[sort] ? 1 : -1);
  }, [products, query, sort]);
  // Only recalculates when products, query, or sort changes
  // NOT on every render
}
// Rule: useMemo for computation that takes >1ms or returns object/array passed to memo child

useRef — mutable values without re-renders

import { useRef, useEffect } from 'react';

// DOM reference
function Input() {
  const inputRef = useRef(null);
  useEffect(() => { inputRef.current.focus(); }, []);
  return ;
}

// Mutable value that does NOT trigger re-render
function StopWatch() {
  const [time, setTime] = useState(0);
  const intervalRef = useRef(null); // Store interval ID without re-render

  const start = () => {
    intervalRef.current = setInterval(() => setTime(t => t + 1), 100);
  };
  const stop = () => clearInterval(intervalRef.current);
}

// Previous value pattern
function usePrevious(value) {
  const ref = useRef();
  useEffect(() => { ref.current = value; }); // Runs after render
  return ref.current; // Returns previous render's value
}

// useState vs useRef:
// useState: triggers re-render when changed
// useRef: no re-render, value persists across renders, synchronous access

Custom hooks — share stateful logic

// Extract stateful logic into reusable hooks
function useLocalStorage(key, defaultValue) {
  const [value, setValue] = useState(() => {
    try { return JSON.parse(localStorage.getItem(key)) ?? defaultValue; }
    catch { return defaultValue; }
  });
  const set = useCallback((newValue) => {
    setValue(newValue);
    localStorage.setItem(key, JSON.stringify(newValue));
  }, [key]);
  return [value, set];
}

// Usage:
const [theme, setTheme] = useLocalStorage('theme', 'light');

// useFetch — data fetching hook
function useFetch(url) {
  const [state, setState] = useState({ data: null, loading: true, error: null });
  useEffect(() => {
    setState(s => ({ ...s, loading: true }));
    fetch(url)
      .then(r => r.json())
      .then(data => setState({ data, loading: false, error: null }))
      .catch(error => setState({ data: null, loading: false, error }));
  }, [url]);
  return state;
}

React Hooks cheat sheet

  • ✅ Always use functional updates: setState(prev => prev + 1)
  • ✅ Always return cleanup from useEffect for subscriptions and timers
  • ✅ ESLint exhaustive-deps rule — never suppress it without understanding why
  • ✅ useCallback only when function is prop to memoized child
  • ✅ useMemo only when computation is measurably expensive
  • ✅ useRef for DOM refs and mutable values that shouldn’t trigger re-renders
  • ❌ Never call hooks inside conditions or loops
  • ❌ Never omit dependencies from useEffect — stale closures are silent bugs

React hooks performance connects to the React performance optimization guide — hooks are the mechanism, performance optimization is the goal. For state management at scale, the JavaScript Proxy guide shows how state management libraries implement reactivity. External reference: React Hooks API reference.

Recommended Reading

Designing Data-Intensive Applications — The essential book every senior developer needs. Covers distributed systems, databases, and production architecture.

The Pragmatic Programmer — Timeless engineering wisdom for writing better, more maintainable code at any level.

Affiliate links. We earn a small commission at no extra cost to you.

Free Weekly Newsletter

🚀 Don’t Miss the Next Cheat Code

Join 1,000+ senior developers getting expert-level JS, Python, AWS, system design and AI secrets every week. Zero fluff, pure signal.

✓ No spam✓ Unsubscribe anytime✓ Expert-level only

Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply