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, and useMemo — covering every pattern, gotcha, and real-world scenario you'll encounter.

Navigation: ← Rules of Hooks · Next → Exercise Questions


Table of Contents

  1. Hook Selection Guide
  2. useState — Complete Reference
  3. useState — Advanced Patterns
  4. useEffect — Complete Reference
  5. useEffect — Advanced Patterns
  6. useContext — Complete Reference
  7. useContext — Advanced Patterns
  8. useRef — Complete Reference
  9. useRef — Advanced Patterns
  10. useCallback — Complete Reference
  11. useCallback — When You Actually Need It
  12. useMemo — Complete Reference
  13. useMemo — When You Actually Need It
  14. Bonus Hooks: useReducer, useId, useTransition
  15. Hooks Comparison Matrix
  16. 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

SituationuseMemouseCallbackNeither
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

FactoruseStateuseReducer
State shapePrimitive or simple objectComplex object, nested, multiple sub-values
Update logicSimple replacementsMultiple types of transitions
Related states1-2 related values3+ values that change together
DebuggingScattered setState callsCentralized, logged actions
TestingTest component directlyTest 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

ScenarioHookWhy
Toggle sidebar visibilityuseStateSimple boolean, affects UI
Fetch data on mountuseEffectSide effect, sync with external
Access current themeuseContextRead from component tree
Auto-focus an inputuseRefDOM reference, imperative action
Expensive list filteringuseMemoCache computation, avoid re-calc
Callback to memoized childuseCallbackStable reference, prevent child re-render
Complex form with validationuseReducerMultiple related states, clear transitions
Form label accessibilityuseIdSSR-safe unique IDs
Tab switching with heavy contentuseTransitionKeep UI responsive during heavy render

16. Key Takeaways

  1. 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.

  2. useEffect: Think "synchronization," not "lifecycle." Always add cleanup for subscriptions, timers, and fetch requests. The dependency array controls WHEN, not IF.

  3. useContext: Great for tree-wide data (theme, auth, locale). Split contexts for performance. Always memoize Provider values.

  4. useRef: Two jobs — DOM references and mutable values that don't trigger re-renders. Use for timers, previous values, and latest callback refs.

  5. useCallback: Only needed when passing functions to React.memo children or using as effect dependencies. Don't wrap every function — profile first.

  6. useMemo: Only needed for expensive computations (>1ms) or stabilizing object references for memoized children. Don't memoize trivial calculations.

  7. useReducer: Reach for it when you have 3+ related state values, complex transitions, or want testable state logic.

  8. The React Compiler (coming) will auto-apply useMemo/useCallback in many cases, reducing the need for manual optimization.

  9. Derive, don't store: If a value can be computed from state/props, compute it during render instead of storing it in state.

  10. 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

  1. 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>;
    }
    
  2. The Re-render Puzzle: Component A uses useContext(ThemeContext). Component B is a child of A but does NOT use useContext. When the theme changes, does B re-render? Explain the mechanism.

  3. 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