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

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

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.

Free Weekly Newsletter

🚀 Don’t Miss the Next Cheat Code

✓ No spam✓ Unsubscribe anytime✓ Expert-level only

Related: React State Management: useState vs useReducer vs Context vs Zustand · Day 12: ReAct vs Plan-Then-Act vs Compiler Agents · Node.js Async Hooks: The Tracing API That Powers Every APM Tool

Related: Lambda Alias Traffic Shifting: Automated Rollback Hooks That Fire · DynamoDB Streams: Session Expiry Hooks for Multi-Turn Coding Agents · Claude Code Hooks: Gate Risky Shell Commands Before CI Runs

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


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 comment

No account needed. Name and email are optional.