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-depsrule — 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.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
