Episode 2 — React Frontend Architecture NextJS / 2.7 — Useful Hooks in React
2.7.c — Commonly Used Hooks
In one sentence: This is the definitive reference for the six hooks you'll use in 90% of React code —
useState,useEffect,useContext,useRef,useCallback, anduseMemo— covering every pattern, gotcha, and real-world scenario you'll encounter.
Navigation: ← Rules of Hooks · Next → Exercise Questions
Table of Contents
- Hook Selection Guide
- useState — Complete Reference
- useState — Advanced Patterns
- useEffect — Complete Reference
- useEffect — Advanced Patterns
- useContext — Complete Reference
- useContext — Advanced Patterns
- useRef — Complete Reference
- useRef — Advanced Patterns
- useCallback — Complete Reference
- useCallback — When You Actually Need It
- useMemo — Complete Reference
- useMemo — When You Actually Need It
- Bonus Hooks: useReducer, useId, useTransition
- Hooks Comparison Matrix
- Key Takeaways
1. Hook Selection Guide
Before diving deep, here's your decision framework:
"I need to..." → Use this hook
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Store data that changes the UI → useState
Store complex state with actions → useReducer
Run code after render (side effects) → useEffect
Run code before paint (measure DOM) → useLayoutEffect
Read theme/auth/locale from tree → useContext
Reference a DOM element → useRef
Store mutable value (no re-render) → useRef
Cache expensive calculation → useMemo
Stabilize function reference → useCallback
Generate SSR-safe unique ID → useId
Mark update as non-urgent → useTransition
Show stale content during computation → useDeferredValue
Quick Reference Card
┌─────────────┬──────────────┬────────────┬──────────────────────┐
│ Hook │ Triggers │ Returns │ Primary Use │
│ │ Re-render? │ │ │
├─────────────┼──────────────┼────────────┼──────────────────────┤
│ useState │ ✅ Yes │ [val, set] │ UI-visible data │
│ useEffect │ ❌ No │ void │ Side effects │
│ useContext │ ✅ Yes* │ value │ Tree-wide data │
│ useRef │ ❌ No │ {current} │ DOM refs, mutables │
│ useCallback │ ❌ No │ function │ Stable fn references │
│ useMemo │ ❌ No │ value │ Cached computations │
│ useReducer │ ✅ Yes │ [st, disp] │ Complex state logic │
│ useId │ ❌ No │ string │ Unique IDs (SSR) │
│ useTransit. │ ✅ Yes │ [pend, fn] │ Non-urgent updates │
└─────────────┴──────────────┴────────────┴──────────────────────┘
* useContext re-renders when the Provider's value changes
2. useState — Complete Reference
Signature
const [state, setState] = useState(initialState);
const [state, setState] = useState(() => computeInitialState()); // Lazy init
Five Ways to Call setState
function Demo() {
const [count, setCount] = useState(0);
const [user, setUser] = useState({ name: 'Alice', age: 25 });
const [items, setItems] = useState(['a', 'b', 'c']);
// 1. Direct value
const reset = () => setCount(0);
// 2. Updater function (when new state depends on previous)
const increment = () => setCount(prev => prev + 1);
// 3. Object state — MUST spread (no auto-merge)
const birthday = () => setUser(prev => ({ ...prev, age: prev.age + 1 }));
// 4. Array state — common operations
const addItem = () => setItems(prev => [...prev, 'd']); // Append
const removeFirst = () => setItems(prev => prev.slice(1)); // Remove
const updateSecond = () => setItems(prev => // Update
prev.map((item, i) => i === 1 ? 'B' : item)
);
// 5. Same value = no re-render (Object.is comparison)
const noOp = () => setCount(0); // If count is already 0, no re-render
}
Lazy Initialization
// ❌ Runs EVERY render (wasted computation)
const [data, setData] = useState(JSON.parse(localStorage.getItem('data')));
// ✅ Runs ONCE on mount (lazy initialization)
const [data, setData] = useState(() => {
const stored = localStorage.getItem('data');
return stored ? JSON.parse(stored) : { items: [], lastUpdated: null };
});
// ✅ Expensive computation — lazy init
const [grid, setGrid] = useState(() => createInitialGrid(100, 100));
The Batching Behavior
function Counter() {
const [count, setCount] = useState(0);
// React 18+: ALL updates are batched automatically
function handleClick() {
setCount(c => c + 1); // Queued
setCount(c => c + 1); // Queued
setCount(c => c + 1); // Queued
// Only ONE re-render happens! count = 3
}
// Even in async contexts (React 18+)
async function handleAsync() {
const data = await fetchData();
setCount(data.count); // Queued
setName(data.name); // Queued
setLoading(false); // Queued
// Still ONE re-render!
}
// To force synchronous (rare, escape hatch):
import { flushSync } from 'react-dom';
function handleForced() {
flushSync(() => setCount(c => c + 1)); // Re-render NOW
// DOM is already updated here
flushSync(() => setFlag(true)); // Re-render NOW again
}
}
When to Use Updater Function vs Direct Value
// Use DIRECT VALUE when new state is independent of previous
setCount(5); // Replace with 5
setName('Bob'); // Replace with 'Bob'
setIsOpen(false); // Replace with false
setItems([1, 2, 3]); // Replace entire array
// Use UPDATER FUNCTION when new state depends on previous
setCount(prev => prev + 1); // Depends on previous count
setItems(prev => [...prev, newItem]); // Depends on previous array
setUser(prev => ({ ...prev, age: 26 })); // Depends on previous object
// CRITICAL: Inside event handlers with multiple setState calls
function handleTripleClick() {
// ❌ These all read the SAME stale 'count' (closure)
setCount(count + 1); // 0 + 1 = 1
setCount(count + 1); // 0 + 1 = 1 (still reads old count!)
setCount(count + 1); // 0 + 1 = 1 (still reads old count!)
// Result: count = 1 (not 3!)
// ✅ Updater functions chain correctly
setCount(prev => prev + 1); // 0 → 1
setCount(prev => prev + 1); // 1 → 2
setCount(prev => prev + 1); // 2 → 3
// Result: count = 3 ✓
}
3. useState — Advanced Patterns
Pattern 1: Multiple Related State → useReducer Signal
When you find yourself with 3+ related useState calls, consider useReducer:
// ❌ Fragmented related state
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// Must remember to update all three together
const fetchData = async () => {
setLoading(true);
setError(null); // Easy to forget this!
try {
const result = await fetch(url);
setData(await result.json());
} catch (e) {
setError(e); // Easy to forget to clear data!
}
setLoading(false);
};
// ✅ Discriminated union with useReducer (impossible invalid states)
const [state, dispatch] = useReducer(fetchReducer, { status: 'idle', data: null, error: null });
function fetchReducer(state, action) {
switch (action.type) {
case 'FETCH_START': return { status: 'loading', data: null, error: null };
case 'FETCH_SUCCESS': return { status: 'success', data: action.data, error: null };
case 'FETCH_ERROR': return { status: 'error', data: null, error: action.error };
default: return state;
}
}
Pattern 2: Derived State (Don't Store What You Can Compute)
// ❌ Storing derived state — gets out of sync
const [items, setItems] = useState([]);
const [filteredItems, setFilteredItems] = useState([]);
const [totalPrice, setTotalPrice] = useState(0);
// Every time items changes, you must remember to update filtered AND total
useEffect(() => {
setFilteredItems(items.filter(i => i.active));
setTotalPrice(items.reduce((sum, i) => sum + i.price, 0));
}, [items]);
// ✅ Derive during render — always consistent, zero sync bugs
const [items, setItems] = useState([]);
const filteredItems = items.filter(i => i.active); // Derived
const totalPrice = items.reduce((sum, i) => sum + i.price, 0); // Derived
// If computation is expensive, wrap in useMemo:
const filteredItems = useMemo(() => items.filter(i => i.active), [items]);
Pattern 3: Resetting State with Key
// Problem: state persists when switching between similar items
function EditForm({ userId }) {
const [name, setName] = useState('');
// When userId changes, name still shows old value!
}
// ✅ Solution: Use key to force remount (resets all state)
function UserPage({ userId }) {
return <EditForm key={userId} userId={userId} />;
// ^^^^^^^^^^^ Forces new instance when userId changes
}
Pattern 4: Functional State for Immutable Updates
// Complex immutable updates with nested objects
const [form, setForm] = useState({
personal: { firstName: '', lastName: '', age: 0 },
address: { street: '', city: '', zip: '' },
preferences: { theme: 'light', notifications: true },
});
// Update nested property
const updateCity = (city) => {
setForm(prev => ({
...prev,
address: {
...prev.address,
city,
},
}));
};
// For deeply nested state, consider Immer:
// npm install use-immer
import { useImmer } from 'use-immer';
const [form, setForm] = useImmer(initialForm);
const updateCity = (city) => {
setForm(draft => {
draft.address.city = city; // Mutate the draft directly!
});
};
4. useEffect — Complete Reference
Signature
useEffect(setup, dependencies?)
// setup: () => void | (() => void) — the effect function (optionally returns cleanup)
// dependencies?: any[] — array of reactive values
The Three Configurations
// 1. No dependency array — runs after EVERY render
useEffect(() => {
console.log('I run after every render');
return () => console.log('I clean up before next run');
});
// Use case: Debugging, analytics that must track every change
// 2. Empty array — runs ONCE after first render
useEffect(() => {
console.log('I run once');
return () => console.log('I clean up on unmount');
}, []);
// Use case: One-time setup (event listeners, timers, subscriptions)
// 3. With dependencies — runs when any dependency changes
useEffect(() => {
console.log(`userId changed to ${userId}`);
return () => console.log(`Cleaning up for ${userId}`);
}, [userId, roomId]);
// Use case: Sync with changing external data
The Effect Lifecycle (Detailed)
Component Mounts:
┌─────────────────────────────────────────────────────────┐
│ 1. React renders component → Virtual DOM │
│ 2. React commits changes → Real DOM updated │
│ 3. Browser paints screen → User sees content │
│ 4. React runs useEffect setup function │
│ (this is AFTER paint, non-blocking) │
└─────────────────────────────────────────────────────────┘
Dependency Changes:
┌─────────────────────────────────────────────────────────┐
│ 1. React renders with new props/state │
│ 2. React commits → DOM updated │
│ 3. Browser paints │
│ 4. React runs CLEANUP from PREVIOUS effect │
│ 5. React runs new setup function │
└─────────────────────────────────────────────────────────┘
Component Unmounts:
┌─────────────────────────────────────────────────────────┐
│ 1. React runs CLEANUP from last effect │
│ 2. Component removed from DOM │
└─────────────────────────────────────────────────────────┘
Dependency Comparison
React uses Object.is() to compare dependencies:
// These are "same" (effect does NOT re-run):
Object.is(3, 3) // true — same number
Object.is('hello', 'hello') // true — same string
Object.is(true, true) // true — same boolean
Object.is(null, null) // true
// These are "different" (effect re-runs):
Object.is({}, {}) // false — different object references!
Object.is([], []) // false — different array references!
Object.is(fn, fn2) // false — different function references!
Object.is(NaN, NaN) // true — special case
// This is why inline objects/arrays/functions in deps cause infinite loops:
useEffect(() => { /* ... */ }, [{ id: 1 }]); // ❌ New object every render!
5. useEffect — Advanced Patterns
Pattern 1: Data Fetching with Cleanup
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [status, setStatus] = useState('idle'); // idle | loading | success | error
useEffect(() => {
if (!userId) return;
const controller = new AbortController();
setStatus('loading');
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
setUser(data);
setStatus('success');
})
.catch(err => {
if (err.name !== 'AbortError') {
setStatus('error');
}
});
return () => controller.abort(); // Cancel on cleanup
}, [userId]);
}
Pattern 2: Subscription Management
function ChatMessages({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const connection = createChatConnection(roomId);
connection.on('message', (msg) => {
setMessages(prev => [...prev, msg]); // Updater — always reads latest
});
connection.connect();
return () => {
connection.disconnect(); // Always clean up subscriptions
};
}, [roomId]); // Re-subscribe when room changes
}
Pattern 3: Debounced Search
function SearchInput() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const timer = setTimeout(() => {
fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then(res => res.json())
.then(setResults);
}, 300); // Wait 300ms after last keystroke
return () => clearTimeout(timer); // Cancel if query changes quickly
}, [query]);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>
</div>
);
}
Pattern 4: Synchronizing with External Store
function WindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []); // Empty — only subscribe once
return <p>{size.width} × {size.height}</p>;
}
Pattern 5: Effect with Ref (avoid stale closures)
// Problem: callback in effect uses stale props/state
function Notification({ onDismiss, timeout }) {
useEffect(() => {
const timer = setTimeout(() => {
onDismiss(); // ⚠️ Might be stale if parent re-renders
}, timeout);
return () => clearTimeout(timer);
}, [onDismiss, timeout]); // Adding onDismiss causes re-subscribe on every render!
// ✅ Solution: useRef for the callback
const onDismissRef = useRef(onDismiss);
onDismissRef.current = onDismiss; // Always up to date
useEffect(() => {
const timer = setTimeout(() => {
onDismissRef.current(); // Always calls the latest version
}, timeout);
return () => clearTimeout(timer);
}, [timeout]); // Only re-subscribe when timeout changes
}
6. useContext — Complete Reference
Signature
const value = useContext(SomeContext);
Creating and Using Context (Complete Pattern)
import { createContext, useContext, useState, useMemo } from 'react';
// Step 1: Create context with default value
const AuthContext = createContext(null);
// Step 2: Create Provider component
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check for existing session
checkSession()
.then(setUser)
.finally(() => setLoading(false));
}, []);
const login = async (email, password) => {
const user = await loginAPI(email, password);
setUser(user);
};
const logout = async () => {
await logoutAPI();
setUser(null);
};
// ✅ Memoize value to prevent unnecessary re-renders
const value = useMemo(() => ({
user, loading, login, logout,
isAuthenticated: !!user,
}), [user, loading]); // login/logout are stable (defined in component)
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
// Step 3: Create custom hook with error handling
function useAuth() {
const context = useContext(AuthContext);
if (context === null) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
// Step 4: Use in components
function UserMenu() {
const { user, logout, isAuthenticated } = useAuth();
if (!isAuthenticated) return <LoginButton />;
return (
<div>
<span>{user.name}</span>
<button onClick={logout}>Logout</button>
</div>
);
}
// Step 5: Wrap app
function App() {
return (
<AuthProvider>
<Header />
<Main />
</AuthProvider>
);
}
7. useContext — Advanced Patterns
Pattern 1: Split Context for Performance
// ❌ Single context — ALL consumers re-render when ANY value changes
const AppContext = createContext({ user: null, theme: 'light', locale: 'en' });
// ✅ Split contexts — consumers only re-render for their specific data
const UserContext = createContext(null);
const ThemeContext = createContext('light');
const LocaleContext = createContext('en');
function AppProviders({ children }) {
return (
<UserProvider>
<ThemeProvider>
<LocaleProvider>
{children}
</LocaleProvider>
</ThemeProvider>
</UserProvider>
);
}
// Component that only cares about theme won't re-render when user changes
function ThemedButton() {
const theme = useContext(ThemeContext); // Only subscribes to theme
return <button className={theme}>Click</button>;
}
Pattern 2: Context + Reducer (mini Redux)
const TodoContext = createContext(null);
const TodoDispatchContext = createContext(null);
function todoReducer(state, action) {
switch (action.type) {
case 'ADD':
return [...state, { id: Date.now(), text: action.text, done: false }];
case 'TOGGLE':
return state.map(t => t.id === action.id ? { ...t, done: !t.done } : t);
case 'DELETE':
return state.filter(t => t.id !== action.id);
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
function TodoProvider({ children }) {
const [todos, dispatch] = useReducer(todoReducer, []);
return (
<TodoContext.Provider value={todos}>
<TodoDispatchContext.Provider value={dispatch}>
{children}
</TodoDispatchContext.Provider>
</TodoContext.Provider>
);
}
// Separate hooks for reading vs dispatching
function useTodos() {
return useContext(TodoContext);
}
function useTodoDispatch() {
return useContext(TodoDispatchContext); // dispatch is stable!
}
// Components that only dispatch don't re-render when todos change
function AddTodo() {
const dispatch = useTodoDispatch(); // Doesn't re-render on todo changes
const [text, setText] = useState('');
return (
<form onSubmit={e => {
e.preventDefault();
dispatch({ type: 'ADD', text });
setText('');
}}>
<input value={text} onChange={e => setText(e.target.value)} />
</form>
);
}
Pattern 3: Compose Multiple Providers
// Avoid deeply nested providers
function ComposeProviders({ providers, children }) {
return providers.reduceRight(
(acc, [Provider, props]) => <Provider {...props}>{acc}</Provider>,
children
);
}
// Usage
function App() {
return (
<ComposeProviders providers={[
[AuthProvider, {}],
[ThemeProvider, { defaultTheme: 'dark' }],
[LocaleProvider, { defaultLocale: 'en' }],
[NotificationProvider, {}],
]}>
<AppContent />
</ComposeProviders>
);
}
8. useRef — Complete Reference
Signature
const ref = useRef(initialValue);
// ref.current = initialValue (mutable, persists across renders)
DOM References
function AutoFocusInput() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus(); // Access DOM node directly
}, []);
return <input ref={inputRef} />;
}
function ScrollToBottom() {
const bottomRef = useRef(null);
const scrollToBottom = () => {
bottomRef.current.scrollIntoView({ behavior: 'smooth' });
};
return (
<div>
{messages.map(msg => <Message key={msg.id} {...msg} />)}
<div ref={bottomRef} />
<button onClick={scrollToBottom}>↓ Bottom</button>
</div>
);
}
Mutable Values (No Re-render)
function Timer() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null); // Store timer ID
const start = () => {
if (intervalRef.current) return; // Already running
intervalRef.current = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
};
const stop = () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
};
// Cleanup on unmount
useEffect(() => {
return () => clearInterval(intervalRef.current);
}, []);
return (
<div>
<p>{seconds}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
9. useRef — Advanced Patterns
Pattern 1: Previous Value
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value; // Update AFTER render
});
return ref.current; // Returns value from PREVIOUS render
}
// Usage
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>Now: {count}, Previously: {prevCount}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
}
Pattern 2: Latest Callback Ref
// Keep a ref to the latest callback to avoid stale closures
function useLatestCallback(callback) {
const ref = useRef(callback);
ref.current = callback; // Update every render
// Return a stable function that always calls the latest version
return useCallback((...args) => ref.current(...args), []);
}
// Usage
function Chat({ onMessage }) {
const stableOnMessage = useLatestCallback(onMessage);
useEffect(() => {
const ws = new WebSocket(url);
ws.onmessage = (event) => stableOnMessage(event.data);
return () => ws.close();
}, []); // No need to add onMessage to deps!
}
Pattern 3: Render Count
function RenderCounter() {
const renderCount = useRef(0);
renderCount.current++;
return <p>Rendered {renderCount.current} times</p>;
}
Pattern 4: Callback Ref (dynamic ref)
// When you need to run code when a ref is attached/detached
function MeasuredBox() {
const [height, setHeight] = useState(0);
// Callback ref — called when element mounts/unmounts
const measuredRef = useCallback((node) => {
if (node !== null) {
setHeight(node.getBoundingClientRect().height);
}
}, []);
return (
<div>
<div ref={measuredRef}>
<p>Content that might change height</p>
</div>
<p>Height: {height}px</p>
</div>
);
}
10. useCallback — Complete Reference
Signature
const memoizedFn = useCallback(fn, dependencies);
// Returns the SAME function reference if dependencies haven't changed
What It Does
function Parent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('Alice');
// ❌ Without useCallback: new function EVERY render
const handleClick = () => {
console.log(count);
};
// handleClick from render 1 !== handleClick from render 2
// ✅ With useCallback: same function reference unless deps change
const handleClick = useCallback(() => {
console.log(count);
}, [count]);
// handleClick only changes when count changes
// If only name changes, handleClick is the SAME reference
}
How It Relates to useMemo
// useCallback is just syntactic sugar for useMemo with a function:
const memoizedFn = useCallback(fn, deps);
// is equivalent to:
const memoizedFn = useMemo(() => fn, deps);
11. useCallback — When You Actually Need It
Default stance: you probably don't need useCallback.
When useCallback IS Necessary
// 1. Passing callbacks to memoized children
const MemoizedChild = React.memo(function Child({ onClick }) {
console.log('Child rendered');
return <button onClick={onClick}>Click</button>;
});
function Parent() {
const [count, setCount] = useState(0);
// ❌ Without useCallback: MemoizedChild re-renders every time Parent re-renders
// because onClick is a new function reference each render
const handleClick = () => console.log('clicked');
// ✅ With useCallback: MemoizedChild only re-renders when handleClick changes
const handleClick = useCallback(() => console.log('clicked'), []);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(c + 1)}>+</button>
<MemoizedChild onClick={handleClick} />
</div>
);
}
// 2. As a dependency in useEffect
function SearchResults({ query }) {
const fetchResults = useCallback(async () => {
const res = await fetch(`/api/search?q=${query}`);
return res.json();
}, [query]);
useEffect(() => {
fetchResults().then(setResults);
}, [fetchResults]); // Stable reference — only re-runs when query changes
}
// 3. Passed to custom hooks that compare references
function useDebounce(callback, delay) {
useEffect(() => {
const timer = setTimeout(callback, delay);
return () => clearTimeout(timer);
}, [callback, delay]); // Needs stable callback reference
}
When useCallback is NOT Necessary
// ❌ Unnecessary — no memoized children, not used as dependency
function SimpleForm() {
const [name, setName] = useState('');
// This is fine without useCallback — no benefit
const handleChange = (e) => setName(e.target.value);
return <input value={name} onChange={handleChange} />;
// <input> is NOT memoized — it re-renders anyway
}
// ❌ Unnecessary — inline handler in JSX
function Button() {
const [count, setCount] = useState(0);
// No benefit to wrapping this in useCallback
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
Decision Flowchart
Is the function passed to a React.memo'd child?
├── Yes → useCallback ✅
└── No
├── Is it used as a dependency in useEffect/useMemo?
│ ├── Yes → useCallback ✅
│ └── No
│ ├── Is it passed to a custom hook that compares refs?
│ │ ├── Yes → useCallback ✅
│ │ └── No → Skip useCallback ❌ (no benefit)
│ └── No → Skip useCallback ❌
12. useMemo — Complete Reference
Signature
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
// Returns the cached result if dependencies haven't changed
What It Does
function ProductList({ products, filter }) {
// ❌ Without useMemo: filters EVERY render, even if products/filter unchanged
const filteredProducts = products.filter(p => p.category === filter);
// ✅ With useMemo: only re-filters when products or filter changes
const filteredProducts = useMemo(
() => products.filter(p => p.category === filter),
[products, filter]
);
return (
<ul>
{filteredProducts.map(p => <ProductCard key={p.id} product={p} />)}
</ul>
);
}
13. useMemo — When You Actually Need It
Default stance: you probably don't need useMemo.
When useMemo IS Necessary
// 1. Expensive calculations
function Analytics({ data }) {
// data has 100,000 rows — expensive to aggregate
const stats = useMemo(() => {
return {
total: data.reduce((sum, row) => sum + row.value, 0),
average: data.reduce((sum, row) => sum + row.value, 0) / data.length,
max: Math.max(...data.map(row => row.value)),
percentiles: computePercentiles(data), // Very expensive
};
}, [data]);
}
// 2. Stabilizing object/array references for memoized children
function Parent() {
const [count, setCount] = useState(0);
// ❌ New object every render — breaks React.memo on Child
const style = { color: 'red', fontSize: 16 };
// ✅ Same object reference unless nothing changed
const style = useMemo(() => ({ color: 'red', fontSize: 16 }), []);
return <MemoizedChild style={style} />;
}
// 3. Stabilizing context values
function ThemeProvider({ children }) {
const [mode, setMode] = useState('light');
// ✅ Prevent ALL context consumers from re-rendering on every parent render
const value = useMemo(() => ({
mode,
toggle: () => setMode(m => m === 'light' ? 'dark' : 'light'),
colors: mode === 'light' ? lightColors : darkColors,
}), [mode]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
When useMemo is NOT Necessary
// ❌ Trivial computation — overhead of useMemo > computation cost
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);
// Just write:
const fullName = `${firstName} ${lastName}`;
// ❌ Already a primitive — Object.is already handles this
const isAdult = useMemo(() => age >= 18, [age]);
// Just write:
const isAdult = age >= 18;
// ❌ Value not passed to anything that compares references
const items = useMemo(() => data.map(transform), [data]);
// If <ul>{items.map(...)}</ul> — no benefit, JSX re-renders anyway
The useMemo / useCallback Decision Table
| Situation | useMemo | useCallback | Neither |
|---|---|---|---|
| Expensive computation (>1ms) | ✅ | ||
| Object/array passed to memo'd child | ✅ | ||
| Context provider value | ✅ | ||
| Function passed to memo'd child | ✅ | ||
| Function used in useEffect deps | ✅ | ||
| Simple string concatenation | ✅ | ||
| Inline event handler | ✅ | ||
| Boolean computation | ✅ |
14. Bonus Hooks: useReducer, useId, useTransition
useReducer — State Machine for Complex Logic
import { useReducer } from 'react';
function formReducer(state, action) {
switch (action.type) {
case 'SET_FIELD':
return {
...state,
values: { ...state.values, [action.field]: action.value },
errors: { ...state.errors, [action.field]: null }, // Clear error on type
};
case 'SET_ERROR':
return { ...state, errors: { ...state.errors, [action.field]: action.error } };
case 'SUBMIT_START':
return { ...state, submitting: true };
case 'SUBMIT_SUCCESS':
return { ...state, submitting: false, submitted: true };
case 'SUBMIT_ERROR':
return { ...state, submitting: false, submitError: action.error };
case 'RESET':
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
const initialState = {
values: { email: '', password: '' },
errors: {},
submitting: false,
submitted: false,
submitError: null,
};
function LoginForm() {
const [state, dispatch] = useReducer(formReducer, initialState);
const handleSubmit = async (e) => {
e.preventDefault();
dispatch({ type: 'SUBMIT_START' });
try {
await login(state.values);
dispatch({ type: 'SUBMIT_SUCCESS' });
} catch (err) {
dispatch({ type: 'SUBMIT_ERROR', error: err.message });
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={state.values.email}
onChange={e => dispatch({ type: 'SET_FIELD', field: 'email', value: e.target.value })}
/>
{state.errors.email && <span>{state.errors.email}</span>}
<button disabled={state.submitting}>
{state.submitting ? 'Logging in...' : 'Login'}
</button>
</form>
);
}
useState vs useReducer Decision
| Factor | useState | useReducer |
|---|---|---|
| State shape | Primitive or simple object | Complex object, nested, multiple sub-values |
| Update logic | Simple replacements | Multiple types of transitions |
| Related states | 1-2 related values | 3+ values that change together |
| Debugging | Scattered setState calls | Centralized, logged actions |
| Testing | Test component directly | Test reducer as pure function |
useId — SSR-Safe Unique IDs
import { useId } from 'react';
function FormField({ label }) {
const id = useId(); // Generates ":r0:", ":r1:", etc. (SSR-safe)
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} />
</div>
);
}
// For multiple related IDs:
function PasswordField() {
const id = useId();
return (
<div>
<label htmlFor={`${id}-input`}>Password</label>
<input id={`${id}-input`} type="password" aria-describedby={`${id}-hint`} />
<p id={`${id}-hint`}>Must be at least 8 characters</p>
</div>
);
}
useTransition — Non-Urgent Updates
import { useState, useTransition } from 'react';
function TabContainer() {
const [tab, setTab] = useState('home');
const [isPending, startTransition] = useTransition();
const switchTab = (nextTab) => {
startTransition(() => {
setTab(nextTab); // Marked as non-urgent — won't block user input
});
};
return (
<div>
<nav>
<button onClick={() => switchTab('home')}>Home</button>
<button onClick={() => switchTab('posts')}>Posts</button>
<button onClick={() => switchTab('settings')}>Settings</button>
</nav>
<div style={{ opacity: isPending ? 0.7 : 1 }}>
{tab === 'home' && <Home />}
{tab === 'posts' && <HeavyPostsList />}
{tab === 'settings' && <Settings />}
</div>
</div>
);
}
15. Hooks Comparison Matrix
┌──────────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┐
│ │ useState │ useEffect│ useContext│ useRef │ useCB │ useMemo │
├──────────────┼──────────┼──────────┼──────────┼──────────┼──────────┼──────────┤
│ Returns │ [val,set]│ void │ value │ {current}│ function │ value │
│ Re-renders? │ ✅ │ ❌ │ ✅* │ ❌ │ ❌ │ ❌ │
│ Runs when? │ render │ after │ render │ render │ render │ render │
│ │ │ paint │ │ │ │ │
│ Dependencies │ n/a │ ✅ │ n/a │ n/a │ ✅ │ ✅ │
│ Cleanup │ n/a │ ✅ │ n/a │ n/a │ n/a │ n/a │
│ Can hold DOM │ ❌ │ ❌ │ ❌ │ ✅ │ ❌ │ ❌ │
│ SSR safe │ ✅ │ ✅** │ ✅ │ ⚠️*** │ ✅ │ ✅ │
└──────────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┘
* Re-renders when Provider value changes
** Effects don't run on server (client-only)
*** useRef to DOM elements must check null (no DOM on server)
When to Pick What: Real Scenarios
| Scenario | Hook | Why |
|---|---|---|
| Toggle sidebar visibility | useState | Simple boolean, affects UI |
| Fetch data on mount | useEffect | Side effect, sync with external |
| Access current theme | useContext | Read from component tree |
| Auto-focus an input | useRef | DOM reference, imperative action |
| Expensive list filtering | useMemo | Cache computation, avoid re-calc |
| Callback to memoized child | useCallback | Stable reference, prevent child re-render |
| Complex form with validation | useReducer | Multiple related states, clear transitions |
| Form label accessibility | useId | SSR-safe unique IDs |
| Tab switching with heavy content | useTransition | Keep UI responsive during heavy render |
16. Key Takeaways
-
useState: The workhorse. Use updater functions when new state depends on previous. Lazy initialization for expensive initial values. Remember: no auto-merge like
this.setState. -
useEffect: Think "synchronization," not "lifecycle." Always add cleanup for subscriptions, timers, and fetch requests. The dependency array controls WHEN, not IF.
-
useContext: Great for tree-wide data (theme, auth, locale). Split contexts for performance. Always memoize Provider values.
-
useRef: Two jobs — DOM references and mutable values that don't trigger re-renders. Use for timers, previous values, and latest callback refs.
-
useCallback: Only needed when passing functions to
React.memochildren or using as effect dependencies. Don't wrap every function — profile first. -
useMemo: Only needed for expensive computations (>1ms) or stabilizing object references for memoized children. Don't memoize trivial calculations.
-
useReducer: Reach for it when you have 3+ related state values, complex transitions, or want testable state logic.
-
The React Compiler (coming) will auto-apply useMemo/useCallback in many cases, reducing the need for manual optimization.
-
Derive, don't store: If a value can be computed from state/props, compute it during render instead of storing it in state.
-
When in doubt, start simple: useState for state, useEffect for effects, useRef for refs. Add useMemo/useCallback only when you measure a performance problem.
Explain-It Challenge
-
The Stale Closure: A developer has this code and the alert always shows 0. Explain why using the "each render has its own snapshot" model, and provide two different fixes:
function Counter() { const [count, setCount] = useState(0); const handleAlert = () => { setTimeout(() => alert(count), 3000); }; return <button onClick={handleAlert}>Alert</button>; } -
The Re-render Puzzle: Component A uses
useContext(ThemeContext). Component B is a child of A but does NOT useuseContext. When the theme changes, does B re-render? Explain the mechanism. -
The Optimization Question: Your manager says "Wrap everything in useMemo and useCallback for performance." Write a response explaining when this helps, when it hurts, and how to decide.
Navigation: ← Rules of Hooks · Next → Exercise Questions