02-hooks-in-depth.md

Module 02: React Hooks In Depth β€” The Modern React Foundation

Estimated Time: 18-22 hours Difficulty: 🟑 Intermediate Prerequisites: Module 01 (Components, Props, State basics) Interview Weight: ⭐⭐⭐⭐⭐ (THE most asked topic in React interviews)


πŸ“‹ Table of Contents

  1. What Are Hooks and Why Do They Exist?
  2. The Rules of Hooks
  3. useState Deep Dive
  4. useEffect Deep Dive
  5. useRef Deep Dive
  6. useContext Deep Dive
  7. useReducer Deep Dive
  8. useMemo and useCallback
  9. useLayoutEffect
  10. React 18+ Hooks: useId, useTransition, useDeferredValue
  11. Custom Hooks β€” Building Your Own
  12. Common Mistakes & Gotchas
  13. Practice Problems
  14. Interview Questions
  15. "What's the Output?" Challenges

1. What Are Hooks and Why Do They Exist?

The Problem Before Hooks

Before hooks (pre-React 16.8, February 2019), if you wanted state or lifecycle behavior, you had to use class components. This led to three major problems:

Problem 1: Reusing Stateful Logic Was Hard

// Before Hooks: Want to share "window resize" logic between components? // You needed Higher-Order Components (HOCs) or Render Props β€” both were clunky. // HOC approach (wrapper hell): const EnhancedComponent = withWindowSize(withTheme(withAuth(MyComponent))); // This creates deeply nested component trees (wrapper hell) // After Hooks: Just call a custom hook function MyComponent() { const size = useWindowSize(); // Reusable! const theme = useTheme(); // Reusable! const auth = useAuth(); // Reusable! // No wrappers, no nesting, just function calls }

Problem 2: Complex Components Became Hard to Understand

// Before Hooks: Related logic was split across lifecycle methods class UserProfile extends React.Component { componentDidMount() { this.fetchUser(); // Fetch user data window.addEventListener('resize', this.handleResize); // Setup resize document.title = `${this.state.user.name}'s Profile`; // Set title } componentDidUpdate(prevProps) { if (prevProps.userId !== this.props.userId) { this.fetchUser(); // Re-fetch if userId changes } document.title = `${this.state.user.name}'s Profile`; // Update title } componentWillUnmount() { window.removeEventListener('resize', this.handleResize); // Cleanup } // The "fetch user" logic is scattered across 3 lifecycle methods! // The "resize" logic is scattered across 2 lifecycle methods! // This makes the code hard to follow and maintain. } // After Hooks: Related logic stays together function UserProfile({ userId }) { // All "fetch user" logic in one place useEffect(() => { fetchUser(userId); }, [userId]); // All "resize" logic in one place useEffect(() => { window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); // All "title" logic in one place useEffect(() => { document.title = `${user.name}'s Profile`; }, [user.name]); }

Problem 3: Classes Are Confusing

// The 'this' keyword in JavaScript is notoriously confusing class Counter extends React.Component { constructor(props) { super(props); // Why do I need this? this.state = { count: 0 }; this.handleClick = this.handleClick.bind(this); // WHY?! } handleClick() { this.setState({ count: this.state.count + 1 }); // If I forget .bind(this) in constructor, 'this' is undefined! } render() { return <button onClick={this.handleClick}>{this.state.count}</button>; } } // With hooks β€” no 'this', no constructor, no binding function Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount(count + 1)}>{count}</button>; }

What Hooks Actually Are

Hooks are functions that let you "hook into" React's internal features (state, lifecycle, context, etc.) from function components.

They're called "hooks" because they hook into React's internal mechanisms:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Your Function Component β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ function Counter() { β”‚ β”‚ β”‚ β”‚ // These "hook into" React internals β”‚ β”‚ β”‚ β”‚ const [count, setCount] = useState(0); │───── Hooks into state system β”‚ β”‚ useEffect(() => {...}, []); │───── Hooks into lifecycle system β”‚ β”‚ const ref = useRef(null); │───── Hooks into ref system β”‚ β”‚ const ctx = useContext(ThemeContext); │───── Hooks into context system β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ return <div>{count}</div>; β”‚ β”‚ β”‚ β”‚ } β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Complete List of Built-in Hooks

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Category β”‚ Hook β”‚ Use β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ State β”‚ useState β”‚ Basic β”‚ β”‚ β”‚ useReducer β”‚ Complex β”‚ │──────────────────┼────────────────────┼─────────── β”‚ Effects β”‚ useEffect β”‚ Common β”‚ β”‚ β”‚ useLayoutEffect β”‚ Sync β”‚ β”‚ β”‚ useInsertionEffectβ”‚ CSS-in-JSβ”‚ │──────────────────┼────────────────────┼─────────── β”‚ Context β”‚ useContext β”‚ Read ctxβ”‚ │──────────────────┼────────────────────┼─────────── β”‚ Refs β”‚ useRef β”‚ DOM/val β”‚ β”‚ β”‚ useImperativeHandleβ”‚ Expose β”‚ │──────────────────┼────────────────────┼─────────── β”‚ Performance β”‚ useMemo β”‚ Cache β”‚ β”‚ β”‚ useCallback β”‚ Cache fnβ”‚ │──────────────────┼────────────────────┼─────────── β”‚ React 18+ β”‚ useId β”‚ IDs β”‚ β”‚ β”‚ useTransition β”‚ Non-urg β”‚ β”‚ β”‚ useDeferredValue β”‚ Defer β”‚ β”‚ β”‚ useSyncExternalStoreβ”‚ Ext β”‚ │──────────────────┼────────────────────┼─────────── β”‚ React 19+ β”‚ useActionState β”‚ Forms β”‚ β”‚ β”‚ useFormStatus β”‚ Forms β”‚ β”‚ β”‚ useOptimistic β”‚ Optimis β”‚ β”‚ β”‚ use β”‚ Promise β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

2. The Rules of Hooks

There are exactly two rules, and breaking them causes bugs:

Rule 1: Only Call Hooks at the Top Level

// ❌ DON'T call hooks inside conditions function Component({ isLoggedIn }) { if (isLoggedIn) { const [name, setName] = useState(''); // ❌ BREAKS! } const [age, setAge] = useState(0); } // ❌ DON'T call hooks inside loops function Component({ items }) { for (let item of items) { const [value, setValue] = useState(item); // ❌ BREAKS! } } // ❌ DON'T call hooks inside nested functions function Component() { const handleClick = () => { const [count, setCount] = useState(0); // ❌ BREAKS! }; } // βœ… DO call hooks at the TOP of your component function Component({ isLoggedIn }) { const [name, setName] = useState(''); // βœ… Always called const [age, setAge] = useState(0); // βœ… Always called // Use the condition later, not around the hook if (isLoggedIn) { // use name here } }

πŸ” Under the Hood: WHY This Rule Exists

React stores hooks in a linked list attached to each component instance. It relies on the call order to know which hook is which:

First render: useState('') β†’ Hook #1 (stored as node 1 in linked list) useState(0) β†’ Hook #2 (stored as node 2 in linked list) useEffect(...) β†’ Hook #3 (stored as node 3 in linked list) Second render: React walks the linked list: node 1, node 2, node 3 useState('') β†’ Returns value from node 1 βœ… useState(0) β†’ Returns value from node 2 βœ… useEffect(...) β†’ Returns value from node 3 βœ… If you skip a hook conditionally: // Skip useState('') because condition is false useState(0) β†’ Returns value from node 1 ❌ WRONG! Gets name's value! useEffect(...) β†’ Returns value from node 2 ❌ WRONG! Gets age's value!
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Internal Hook Linked List (simplified): β”‚ β”‚ β”‚ β”‚ Render 1: β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Hook 1 │───▢│ Hook 2 │───▢│ Hook 3 β”‚ β”‚ β”‚ β”‚ state: ''β”‚ β”‚ state: 0 β”‚ β”‚ effect β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ ↑ β”‚ β”‚ cursor β”‚ β”‚ β”‚ β”‚ Render 2: cursor resets to Hook 1, walks forward β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ Hook 1 │───▢│ Hook 2 │───▢│ Hook 3 β”‚ β”‚ β”‚ β”‚ state: ''β”‚ β”‚ state: 0 β”‚ β”‚ effect β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ ↑ β”‚ β”‚ cursor starts here, moves right with each hook β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Rule 2: Only Call Hooks from React Functions

// βœ… Call hooks from React function components function MyComponent() { const [count, setCount] = useState(0); // βœ… } // βœ… Call hooks from custom hooks function useCustomHook() { const [value, setValue] = useState(''); // βœ… } // ❌ DON'T call hooks from regular JavaScript functions function calculateTotal(items) { const [total, setTotal] = useState(0); // ❌ Not a React component or hook! } // ❌ DON'T call hooks from class components class MyComponent extends React.Component { render() { const [count, setCount] = useState(0); // ❌ Hooks don't work in classes! } }

ESLint Plugin

React provides an ESLint plugin that enforces these rules automatically:

npm install eslint-plugin-react-hooks
{ "plugins": ["react-hooks"], "rules": { "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn" } }

3. useState Deep Dive

Basic Syntax Recap

const [state, setState] = useState(initialValue);

Lazy Initialization

When the initial value is expensive to compute, pass a function to useState:

// ❌ Expensive β€” runs on EVERY render (even though result is only used on first) const [data, setData] = useState( JSON.parse(localStorage.getItem('hugeData')) // Runs every render! ); // βœ… Lazy initializer β€” function runs only on FIRST render const [data, setData] = useState(() => { console.log('This only runs once!'); return JSON.parse(localStorage.getItem('hugeData')); }); // βœ… Another example β€” expensive computation const [sortedItems, setSortedItems] = useState(() => { return items.sort((a, b) => a.name.localeCompare(b.name)); });

State Batching (React 18+)

React batches multiple state updates into a single re-render:

function Counter() { const [count, setCount] = useState(0); const [flag, setFlag] = useState(false); const handleClick = () => { setCount(c => c + 1); // Does NOT re-render yet setFlag(f => !f); // Does NOT re-render yet // React batches both updates into ONE re-render // Component renders once with both new values }; console.log('Render!'); // Logs only ONCE per click return ( <button onClick={handleClick}> Count: {count}, Flag: {String(flag)} </button> ); }

React 18 Batching Improvement

// React 17: Batching ONLY worked in React event handlers // React 18: Batching works EVERYWHERE (automatic batching) function App() { const [count, setCount] = useState(0); const [flag, setFlag] = useState(false); // βœ… React 17 & 18: Batched (React event handler) const handleClick = () => { setCount(c => c + 1); setFlag(f => !f); // One render }; // βœ… React 18: NOW also batched! // ❌ React 17: NOT batched (two renders!) const handleAsync = () => { fetch('/api/data').then(() => { setCount(c => c + 1); // React 17: re-render here setFlag(f => !f); // React 17: re-render again! // React 18: ONE re-render for both }); }; // βœ… React 18: Also batched in setTimeout, setInterval, native events const handleTimeout = () => { setTimeout(() => { setCount(c => c + 1); setFlag(f => !f); // React 18: ONE re-render }, 1000); }; }

Opting Out of Batching (Rare)

import { flushSync } from 'react-dom'; function handleClick() { flushSync(() => { setCount(c => c + 1); }); // DOM is updated here flushSync(() => { setFlag(f => !f); }); // DOM is updated here // Two separate renders β€” usually not what you want }

State Update Reference Equality

function App() { const [count, setCount] = useState(0); const handleClick = () => { setCount(0); // Same value as current state // React BAILS OUT of re-rendering! // (uses Object.is() comparison) }; console.log('Render'); // Does NOT log on click if count is already 0 // ⚠️ BUT: React may still call your component once more to verify // The render is "wasted" (no DOM update), but the function runs // This is an implementation detail, don't rely on it }

useState with Objects β€” The Merge Problem

// ⚠️ useState does NOT auto-merge like class component's setState // Class component (auto-merges): this.setState({ name: 'Alice' }); // If state was { name: 'Bob', age: 28 } // Result: { name: 'Alice', age: 28 } ← age preserved! // useState (does NOT merge): const [user, setUser] = useState({ name: 'Bob', age: 28 }); setUser({ name: 'Alice' }); // Result: { name: 'Alice' } ← age is GONE! // βœ… You must spread manually: setUser(prev => ({ ...prev, name: 'Alice' })); // Result: { name: 'Alice', age: 28 } ← age preserved

4. useEffect Deep Dive

What is useEffect?

useEffect lets you perform side effects in function components. A "side effect" is anything that reaches outside the component to interact with the world:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Examples of Side Effects: β”‚ β”‚ β”‚ β”‚ β€’ Fetching data from an API β”‚ β”‚ β€’ Setting up subscriptions (WebSocket, etc.) β”‚ β”‚ β€’ Manually changing the DOM β”‚ β”‚ β€’ Setting up event listeners β”‚ β”‚ β€’ Setting timers (setTimeout, setInterval) β”‚ β”‚ β€’ Logging/analytics β”‚ β”‚ β€’ Reading from localStorage β”‚ β”‚ β€’ Changing the document title β”‚ β”‚ β”‚ β”‚ Things that are NOT side effects: β”‚ β”‚ β€’ Computing a value from state/props β”‚ β”‚ β€’ Rendering JSX β”‚ β”‚ β€’ Filtering/sorting data for display β”‚ β”‚ These should happen during render, not in β”‚ β”‚ useEffect! β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The Three Forms of useEffect

// Form 1: Runs after EVERY render (mount + every update) useEffect(() => { console.log('I run after every render'); }); // No dependency array β€” rarely used, usually a mistake // Form 2: Runs ONCE after first render (mount only) useEffect(() => { console.log('I run once on mount'); }, []); // Empty dependency array = no dependencies = run only on mount // Form 3: Runs when specific values change useEffect(() => { console.log(`userId changed to: ${userId}`); fetchUserData(userId); }, [userId]); // Runs on mount AND whenever userId changes

πŸ” Under the Hood: useEffect Timing

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Component Render Cycle with useEffect: β”‚ β”‚ β”‚ β”‚ 1. React calls your component function β”‚ β”‚ └── All hooks run (useState, useRef, etc.) β”‚ β”‚ └── JSX is returned β”‚ β”‚ β”‚ β”‚ 2. React updates the DOM β”‚ β”‚ └── Real DOM is now in sync with Virtual DOM β”‚ β”‚ β”‚ β”‚ 3. Browser paints the screen β”‚ β”‚ └── User can see the updated UI β”‚ β”‚ β”‚ β”‚ 4. useEffect callbacks run (ASYNCHRONOUSLY) β”‚ β”‚ └── This is AFTER paint, not before! β”‚ β”‚ └── This is why useEffect doesn't block β”‚ β”‚ the browser from painting β”‚ β”‚ β”‚ β”‚ Timeline: β”‚ β”‚ render β†’ DOM update β†’ paint β†’ useEffect β”‚ β”‚ β”‚ β”‚ vs useLayoutEffect: β”‚ β”‚ render β†’ DOM update β†’ useLayoutEffect β†’ paint β”‚ β”‚ (we'll cover this in Section 9) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The Cleanup Function

useEffect can return a function that React calls to "clean up" before the next effect runs or when the component unmounts:

function ChatRoom({ roomId }) { useEffect(() => { // SETUP: Connect to the chat room const connection = createConnection(roomId); connection.connect(); console.log(`Connected to room ${roomId}`); // CLEANUP: Disconnect when roomId changes or component unmounts return () => { connection.disconnect(); console.log(`Disconnected from room ${roomId}`); }; }, [roomId]); return <div>Chat room: {roomId}</div>; }

When Does Cleanup Run?

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Lifecycle of an Effect with Cleanup: β”‚ β”‚ β”‚ β”‚ Mount (roomId = "general"): β”‚ β”‚ 1. Component renders β”‚ β”‚ 2. Effect runs: connect to "general" β”‚ β”‚ β”‚ β”‚ Update (roomId changes to "random"): β”‚ β”‚ 1. Component re-renders β”‚ β”‚ 2. CLEANUP runs for OLD effect: disconnect β”‚ β”‚ from "general" β”‚ β”‚ 3. NEW effect runs: connect to "random" β”‚ β”‚ β”‚ β”‚ Unmount: β”‚ β”‚ 1. CLEANUP runs: disconnect from "random" β”‚ β”‚ β”‚ β”‚ Summary: β”‚ β”‚ β€’ Cleanup runs BEFORE the next effect β”‚ β”‚ β€’ Cleanup runs when the component unmounts β”‚ β”‚ β€’ Cleanup captures the values from its render β”‚ β”‚ (closures!) β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Common useEffect Patterns

Pattern 1: Data Fetching

function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let isCancelled = false; // Prevent setting state on unmounted component async function fetchUser() { setLoading(true); setError(null); try { const response = await fetch(`/api/users/${userId}`); if (!response.ok) throw new Error('Failed to fetch'); const data = await response.json(); if (!isCancelled) { // Only update if component is still mounted setUser(data); } } catch (err) { if (!isCancelled) { setError(err.message); } } finally { if (!isCancelled) { setLoading(false); } } } fetchUser(); return () => { isCancelled = true; // Cleanup: cancel pending state updates }; }, [userId]); if (loading) return <p>Loading...</p>; if (error) return <p>Error: {error}</p>; if (!user) return <p>No user found</p>; return <div>{user.name}</div>; }

🏒 Real-World Note: In production, you'd use a data-fetching library like React Query (TanStack Query) instead of raw useEffect for fetching. It handles caching, deduplication, retries, loading states, and more. We'll cover this in πŸ”— Module 04.

Pattern 2: Event Listener

function useWindowSize() { const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight, }); useEffect(() => { const handleResize = () => { setSize({ width: window.innerWidth, height: window.innerHeight, }); }; window.addEventListener('resize', handleResize); // Cleanup: remove the listener when component unmounts return () => window.removeEventListener('resize', handleResize); }, []); // Empty deps β€” only set up once return size; } // Usage: function Header() { const { width } = useWindowSize(); return <header>{width < 768 ? <MobileNav /> : <DesktopNav />}</header>; }

Pattern 3: Document Title

function useDocumentTitle(title) { useEffect(() => { const previousTitle = document.title; document.title = title; return () => { document.title = previousTitle; // Restore on unmount }; }, [title]); } // Usage: function ProfilePage({ user }) { useDocumentTitle(`${user.name}'s Profile`); return <div>...</div>; }

Pattern 4: Debounced Search

function SearchComponent() { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); useEffect(() => { if (!query.trim()) { setResults([]); return; } // Set up a timer to delay the API call const timer = setTimeout(async () => { const response = await fetch(`/api/search?q=${query}`); const data = await response.json(); setResults(data); }, 300); // Wait 300ms after user stops typing // Cleanup: cancel the timer if query changes before 300ms return () => clearTimeout(timer); }, [query]); return ( <div> <input value={query} onChange={e => setQuery(e.target.value)} /> <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul> </div> ); }

The Dependency Array β€” Most Common Source of Bugs

// The dependency array tells React: "Re-run this effect when these values change" const [count, setCount] = useState(0); const [name, setName] = useState(''); // ❌ Missing dependency β€” STALE CLOSURE BUG useEffect(() => { const timer = setInterval(() => { console.log(count); // Always logs the INITIAL count (0)! // count is captured in a closure from the first render }, 1000); return () => clearInterval(timer); }, []); // Empty deps β€” effect never re-runs, count is stale // βœ… Include the dependency useEffect(() => { const timer = setInterval(() => { console.log(count); // Logs current count }, 1000); return () => clearInterval(timer); }, [count]); // Re-runs when count changes // βœ… Even better β€” use functional update to avoid the dependency useEffect(() => { const timer = setInterval(() => { setCount(prev => prev + 1); // No dependency on count! }, 1000); return () => clearInterval(timer); }, []); // Safe with empty deps because we don't READ count

πŸ” Understanding Closures in Effects

function Counter() { const [count, setCount] = useState(0); useEffect(() => { // This callback "closes over" the count value from THIS render setTimeout(() => { alert(`Count was: ${count}`); }, 3000); }, [count]); return ( <div> <p>{count}</p> <button onClick={() => setCount(c => c + 1)}>+</button> </div> ); // If you click 5 times quickly, then wait: // You'll get 5 alerts showing: 0, 1, 2, 3, 4 // Each effect captured the count value at the time it ran // This is different from class components where this.state // always points to the LATEST state }

What NOT to Put in useEffect

// ❌ Don't compute derived values in useEffect function ProductPage({ items }) { const [filteredItems, setFilteredItems] = useState([]); const [searchTerm, setSearchTerm] = useState(''); // ❌ WRONG β€” unnecessary effect, causes extra re-render useEffect(() => { setFilteredItems(items.filter(item => item.name.includes(searchTerm) )); }, [items, searchTerm]); // βœ… CORRECT β€” compute during render (no effect needed) const filteredItems = items.filter(item => item.name.includes(searchTerm) ); // This is simpler, faster (no extra render), and more predictable } // ❌ Don't update state based on props in useEffect function Toggle({ isOpen }) { const [open, setOpen] = useState(isOpen); // ❌ WRONG β€” syncing state with props via effect useEffect(() => { setOpen(isOpen); }, [isOpen]); // βœ… CORRECT β€” either use the prop directly, or use a key to reset // Option A: Just use the prop // Option B: <Toggle key={isOpen} isOpen={isOpen} /> }

5. useRef Deep Dive

What is useRef?

useRef returns a mutable object with a .current property that persists across renders without causing re-renders when changed.

const myRef = useRef(initialValue); // myRef = { current: initialValue } // You can read/write myRef.current freely // Changing myRef.current does NOT cause a re-render

Two Main Use Cases

Use Case 1: Accessing DOM Elements

function TextInput() { const inputRef = useRef(null); const handleFocus = () => { // Access the actual DOM element inputRef.current.focus(); inputRef.current.style.borderColor = 'blue'; }; return ( <div> <input ref={inputRef} type="text" placeholder="Click the button" /> <button onClick={handleFocus}>Focus Input</button> </div> ); }

Use Case 2: Storing Mutable Values (Without Re-rendering)

function Stopwatch() { const [time, setTime] = useState(0); const [isRunning, setIsRunning] = useState(false); const intervalRef = useRef(null); // Store the interval ID const start = () => { if (isRunning) return; setIsRunning(true); intervalRef.current = setInterval(() => { setTime(prev => prev + 10); // Increment by 10ms }, 10); }; const stop = () => { setIsRunning(false); clearInterval(intervalRef.current); // Use the stored interval ID }; const reset = () => { stop(); setTime(0); }; // Cleanup on unmount useEffect(() => { return () => clearInterval(intervalRef.current); }, []); const formatTime = (ms) => { const minutes = Math.floor(ms / 60000); const seconds = Math.floor((ms % 60000) / 1000); const centiseconds = Math.floor((ms % 1000) / 10); return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(centiseconds).padStart(2, '0')}`; }; return ( <div> <h1>{formatTime(time)}</h1> <button onClick={start} disabled={isRunning}>Start</button> <button onClick={stop} disabled={!isRunning}>Stop</button> <button onClick={reset}>Reset</button> </div> ); }

πŸ“Š useRef vs useState

FeatureuseRefuseState
Triggers re-render?❌ Noβœ… Yes
Persists across renders?βœ… Yesβœ… Yes
Mutable?βœ… Yes (direct mutation)❌ No (use setter)
Good forDOM refs, timers, previous valuesUI data that should trigger updates
When value changesNothing happens (no re-render)Component re-renders
Synchronous?βœ… Updated immediately❌ Batched, available next render

Tracking Previous Values with useRef

function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }, [value]); return ref.current; // Returns OLD value (from before this render) } // Usage: function Counter() { const [count, setCount] = useState(0); const prevCount = usePrevious(count); return ( <div> <p>Current: {count}, Previous: {prevCount}</p> <button onClick={() => setCount(c => c + 1)}>+</button> </div> ); // Click: Current: 1, Previous: 0 // Click: Current: 2, Previous: 1 // Click: Current: 3, Previous: 2 }

Render Count Tracking

function RenderCounter() { const renderCount = useRef(0); // This increments every render, but doesn't CAUSE a render renderCount.current += 1; return <p>This component has rendered {renderCount.current} times</p>; } // If you tried this with useState: // const [renderCount, setRenderCount] = useState(0); // setRenderCount(c => c + 1); // This would cause INFINITE re-renders!

Callback Refs (Advanced)

// When you need to know when a ref attaches/detaches, use a callback ref function MeasuredComponent() { const [height, setHeight] = useState(0); // Callback ref: React calls this function with the DOM node const measuredRef = (node) => { if (node !== null) { setHeight(node.getBoundingClientRect().height); } }; return ( <div> <div ref={measuredRef} style={{ padding: '20px', background: '#eee' }}> <p>This content has dynamic height</p> <p>More content...</p> </div> <p>Measured height: {height}px</p> </div> ); }

6. useContext Deep Dive

The Problem: Prop Drilling

Without Context β€” "Prop Drilling": App (has theme = "dark") β”‚ └─ Layout (receives theme, doesn't use it) β”‚ └─ Sidebar (receives theme, doesn't use it) β”‚ └─ Button (finally uses theme!) Every intermediate component must pass theme as a prop, even though they don't use it themselves. Messy!

The Solution: Context

import { createContext, useContext, useState } from 'react'; // Step 1: Create a context const ThemeContext = createContext('light'); // Default value // Step 2: Create a Provider component function ThemeProvider({ children }) { const [theme, setTheme] = useState('light'); const toggleTheme = () => { setTheme(prev => prev === 'light' ? 'dark' : 'light'); }; return ( <ThemeContext.Provider value={{ theme, toggleTheme }}> {children} </ThemeContext.Provider> ); } // Step 3: Use the context in any descendant component function ThemeToggleButton() { const { theme, toggleTheme } = useContext(ThemeContext); return ( <button onClick={toggleTheme} style={{ backgroundColor: theme === 'dark' ? '#333' : '#fff', color: theme === 'dark' ? '#fff' : '#333', padding: '10px 20px', border: '2px solid', borderRadius: '8px', cursor: 'pointer', }} > Current theme: {theme} (Click to toggle) </button> ); } // Step 4: Wrap your app with the Provider function App() { return ( <ThemeProvider> <Layout> <Sidebar> <ThemeToggleButton /> {/* No prop drilling! Button directly accesses theme */} </Sidebar> </Layout> </ThemeProvider> ); }

πŸ” Under the Hood: How Context Works

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Context Architecture: β”‚ β”‚ β”‚ β”‚ ThemeContext.Provider value={{ theme, toggle }} β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ React stores the value internally β”‚ β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ ComponentA (no useContext β†’ skipped) β”‚ β”‚ β”‚ └── ComponentB (no useContext β†’ skip) β”‚ β”‚ β”‚ └── ComponentC (useContext!) β”‚ β”‚ β”‚ └── React walks UP the tree β”‚ β”‚ β”‚ to find nearest Provider β”‚ β”‚ β”‚ and returns its value β”‚ β”‚ β”‚ β”‚ β”‚ └── ComponentD (useContext!) β”‚ β”‚ └── Same: walks up, finds Provider, β”‚ β”‚ returns value β”‚ β”‚ β”‚ β”‚ When Provider's value changes: β”‚ β”‚ ALL consumers (useContext) re-render! β”‚ β”‚ Even if they only use part of the value! β”‚ β”‚ This is Context's biggest performance pitfall. β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

⚠️ Context Performance Issue

// ❌ Problem: ALL consumers re-render when ANY part of value changes function AppProvider({ children }) { const [user, setUser] = useState(null); const [theme, setTheme] = useState('light'); const [notifications, setNotifications] = useState([]); // Every time ANY of these change, ALL consumers re-render return ( <AppContext.Provider value={{ user, setUser, theme, setTheme, notifications, setNotifications }}> {children} </AppContext.Provider> ); } // βœ… Solution: Split into separate contexts function App() { return ( <UserProvider> <ThemeProvider> <NotificationProvider> {children} </NotificationProvider> </ThemeProvider> </UserProvider> ); } // Now changing theme doesn't re-render user consumers

Real-World Context Example: Authentication

import { createContext, useContext, useState, useEffect } from 'react'; const AuthContext = createContext(null); function AuthProvider({ children }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { // Check for existing session on mount async function checkAuth() { try { const response = await fetch('/api/auth/me'); if (response.ok) { const userData = await response.json(); setUser(userData); } } catch (error) { console.error('Auth check failed:', error); } finally { setLoading(false); } } checkAuth(); }, []); const login = async (email, password) => { const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); if (!response.ok) throw new Error('Login failed'); const userData = await response.json(); setUser(userData); return userData; }; const logout = async () => { await fetch('/api/auth/logout', { method: 'POST' }); setUser(null); }; return ( <AuthContext.Provider value={{ user, loading, login, logout }}> {children} </AuthContext.Provider> ); } // Custom hook for easy consumption function useAuth() { const context = useContext(AuthContext); if (context === null) { throw new Error('useAuth must be used within an AuthProvider'); } return context; } // Usage in components: function Navbar() { const { user, logout, loading } = useAuth(); if (loading) return <nav>Loading...</nav>; return ( <nav> {user ? ( <> <span>Welcome, {user.name}!</span> <button onClick={logout}>Logout</button> </> ) : ( <a href="/login">Login</a> )} </nav> ); } function ProtectedRoute({ children }) { const { user, loading } = useAuth(); if (loading) return <LoadingSpinner />; if (!user) return <Navigate to="/login" />; return children; }

7. useReducer Deep Dive

When useState Isn't Enough

useReducer is like useState but for complex state logic. If you know Redux, it's the same concept at the component level.

Real-World Analogy

Think of a bank ATM:

  • State = your bank balance
  • Actions = operations (deposit, withdraw, check balance)
  • Reducer = the bank's rule engine that processes each action
  • Dispatch = pressing a button on the ATM
import { useReducer } from 'react'; // The reducer function β€” pure function that takes state + action, returns NEW state function counterReducer(state, action) { switch (action.type) { case 'INCREMENT': return { count: state.count + 1 }; case 'DECREMENT': return { count: state.count - 1 }; case 'RESET': return { count: 0 }; case 'SET': return { count: action.payload }; default: throw new Error(`Unknown action: ${action.type}`); } } function Counter() { const [state, dispatch] = useReducer(counterReducer, { count: 0 }); return ( <div> <p>Count: {state.count}</p> <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button> <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button> <button onClick={() => dispatch({ type: 'RESET' })}>Reset</button> <button onClick={() => dispatch({ type: 'SET', payload: 100 })}> Set to 100 </button> </div> ); }

Complex State Example: Shopping Cart

const initialState = { items: [], total: 0, itemCount: 0, }; function cartReducer(state, action) { switch (action.type) { case 'ADD_ITEM': { const existingItem = state.items.find(i => i.id === action.payload.id); let updatedItems; if (existingItem) { updatedItems = state.items.map(item => item.id === action.payload.id ? { ...item, quantity: item.quantity + 1 } : item ); } else { updatedItems = [...state.items, { ...action.payload, quantity: 1 }]; } return { items: updatedItems, total: updatedItems.reduce((sum, i) => sum + i.price * i.quantity, 0), itemCount: updatedItems.reduce((sum, i) => sum + i.quantity, 0), }; } case 'REMOVE_ITEM': { const updatedItems = state.items.filter(i => i.id !== action.payload); return { items: updatedItems, total: updatedItems.reduce((sum, i) => sum + i.price * i.quantity, 0), itemCount: updatedItems.reduce((sum, i) => sum + i.quantity, 0), }; } case 'UPDATE_QUANTITY': { const updatedItems = state.items.map(item => item.id === action.payload.id ? { ...item, quantity: action.payload.quantity } : item ).filter(item => item.quantity > 0); return { items: updatedItems, total: updatedItems.reduce((sum, i) => sum + i.price * i.quantity, 0), itemCount: updatedItems.reduce((sum, i) => sum + i.quantity, 0), }; } case 'CLEAR_CART': return initialState; default: throw new Error(`Unknown action: ${action.type}`); } } function ShoppingCart() { const [cart, dispatch] = useReducer(cartReducer, initialState); const addToCart = (product) => { dispatch({ type: 'ADD_ITEM', payload: product }); }; const removeFromCart = (productId) => { dispatch({ type: 'REMOVE_ITEM', payload: productId }); }; return ( <div> <h2>Cart ({cart.itemCount} items)</h2> {cart.items.map(item => ( <div key={item.id}> <span>{item.name} x{item.quantity}</span> <span>${(item.price * item.quantity).toFixed(2)}</span> <button onClick={() => removeFromCart(item.id)}>Remove</button> </div> ))} <h3>Total: ${cart.total.toFixed(2)}</h3> </div> ); }

πŸ“Š useState vs useReducer

FeatureuseStateuseReducer
Best forSimple, independent stateComplex state with many transitions
Update syntaxsetState(newValue)dispatch({ type, payload })
State shapeSingle valueObject with multiple fields
Related updatesManual coordinationSingle reducer handles all
TestabilityInline logicPure function, easy to test
DebuggingHarder (scattered setState)Easier (centralized, loggable)
Learning curveLowMedium

When to use useReducer:

  • State has many related sub-values (like a form with 10+ fields)
  • State transitions are complex (like a state machine)
  • Next state depends on multiple pieces of current state
  • You want testable, centralized state logic

8. useMemo and useCallback

The Re-render Problem

Every time a component re-renders, ALL code inside it runs again:

function ExpensiveComponent({ items, filter }) { // ❌ This expensive filter runs on EVERY render // Even if 'items' and 'filter' haven't changed! const filteredItems = items .filter(item => item.category === filter) .sort((a, b) => a.price - b.price) .map(item => ({ ...item, discountedPrice: item.price * 0.9 })); return <ItemList items={filteredItems} />; }

useMemo β€” Cache Expensive Computations

import { useMemo } from 'react'; function ExpensiveComponent({ items, filter }) { // βœ… Only re-computes when items or filter change const filteredItems = useMemo(() => { console.log('Computing filtered items...'); return items .filter(item => item.category === filter) .sort((a, b) => a.price - b.price) .map(item => ({ ...item, discountedPrice: item.price * 0.9 })); }, [items, filter]); return <ItemList items={filteredItems} />; }

useCallback β€” Cache Function References

import { useCallback, useState, memo } from 'react'; // Memoized child β€” only re-renders if props change const ExpensiveChild = memo(function ExpensiveChild({ onClick, data }) { console.log('ExpensiveChild rendered'); return <button onClick={onClick}>{data}</button>; }); function Parent() { const [count, setCount] = useState(0); const [text, setText] = useState(''); // ❌ Without useCallback: new function created every render // ExpensiveChild re-renders even though its logic hasn't changed const handleClickBad = () => { console.log('clicked'); }; // βœ… With useCallback: same function reference across renders const handleClick = useCallback(() => { console.log('clicked'); }, []); // No dependencies β€” same function forever return ( <div> <input value={text} onChange={e => setText(e.target.value)} /> <p>Count: {count}</p> <button onClick={() => setCount(c => c + 1)}>Increment</button> {/* This won't re-render when text/count changes */} <ExpensiveChild onClick={handleClick} data="Click me" /> </div> ); }

πŸ” How useMemo and useCallback Relate

// useCallback is just a shorthand for useMemo with a function: // These are equivalent: const memoizedFn = useCallback(() => { doSomething(a, b); }, [a, b]); const memoizedFn = useMemo(() => { return () => { doSomething(a, b); }; }, [a, b]); // useCallback(fn, deps) === useMemo(() => fn, deps)

⚠️ When NOT to Use useMemo/useCallback

// ❌ Don't memoize cheap operations const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]); // String concatenation is fast β€” useMemo adds MORE overhead than it saves // ❌ Don't memoize if no child depends on referential equality const handleClick = useCallback(() => { setCount(c => c + 1); }, []); // If no memoized child receives this function, useCallback is pointless // βœ… DO use useMemo for genuinely expensive computations const sortedData = useMemo(() => { return data.slice().sort((a, b) => complexSort(a, b)); // O(n log n) }, [data]); // βœ… DO use useCallback when passing to memoized children const MemoChild = memo(Child); const handleClick = useCallback(() => {...}, []); <MemoChild onClick={handleClick} /> // Now MemoChild won't re-render needlessly

Decision Chart

Should I use useMemo/useCallback? Is the computation genuinely expensive? β”œβ”€β”€ YES β†’ Use useMemo βœ… └── NO β”‚ Am I passing a function/object to a memoized child (React.memo)? β”œβ”€β”€ YES β†’ Use useCallback/useMemo βœ… └── NO β”‚ Am I passing a value as a dependency to another hook? β”œβ”€β”€ YES β†’ Maybe use useMemo (to stabilize reference) └── NO β†’ Don't use it ❌ (adds complexity without benefit)

9. useLayoutEffect

useEffect vs useLayoutEffect

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ useEffect (asynchronous β€” runs AFTER paint): β”‚ β”‚ β”‚ β”‚ Render β†’ DOM Update β†’ Browser Paint β†’ useEffect β”‚ β”‚ └── User sees β”‚ β”‚ the update β”‚ β”‚ BEFORE β”‚ β”‚ effect runs β”‚ β”‚ β”‚ β”‚ useLayoutEffect (synchronous β€” runs BEFORE paint): β”‚ β”‚ β”‚ β”‚ Render β†’ DOM Update β†’ useLayoutEffect β†’ Browser Paint β”‚ β”‚ └── Blocks paint! β”‚ β”‚ User doesn't see until β”‚ β”‚ effect completes β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

When to Use useLayoutEffect

// Use useLayoutEffect when you need to: // 1. Measure DOM elements before the browser paints // 2. Synchronously update the DOM before the user sees it function Tooltip({ targetRef, children }) { const tooltipRef = useRef(null); const [position, setPosition] = useState({ top: 0, left: 0 }); // βœ… useLayoutEffect β€” prevents the tooltip from flickering useLayoutEffect(() => { const targetRect = targetRef.current.getBoundingClientRect(); const tooltipRect = tooltipRef.current.getBoundingClientRect(); setPosition({ top: targetRect.top - tooltipRect.height - 8, left: targetRect.left + (targetRect.width - tooltipRect.width) / 2, }); }, []); // If we used useEffect here, the tooltip would briefly appear at (0,0) // then jump to the correct position β€” causing a visual "flash" return ( <div ref={tooltipRef} style={{ position: 'absolute', top: position.top, left: position.left, }} > {children} </div> ); }

πŸ“Š useEffect vs useLayoutEffect

FeatureuseEffectuseLayoutEffect
TimingAfter paint (async)Before paint (sync)
Blocks paint?NoYes
Use forData fetching, subscriptions, loggingDOM measurements, preventing visual flicker
PerformanceBetter (non-blocking)Can cause jank if slow
SSRWorks⚠️ Warning on server (no DOM)
Default choiceβœ… YesOnly when useEffect causes visual issues

10. React 18+ Hooks: useId, useTransition, useDeferredValue

useId β€” Generating Unique IDs

import { useId } from 'react'; function FormField({ label }) { const id = useId(); // Generates a unique ID like ":r1:" or ":r2:" return ( <div> <label htmlFor={id}>{label}</label> <input id={id} type="text" /> </div> ); } // Why not just use Math.random()? // 1. useId is consistent between server and client (important for SSR) // 2. Math.random() generates different values on server vs client (hydration mismatch) // 3. useId is deterministic based on the component tree position

useTransition β€” Non-Urgent State Updates

import { useState, useTransition } from 'react'; function SearchResults() { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [isPending, startTransition] = useTransition(); const handleSearch = (e) => { const value = e.target.value; // This update is URGENT β€” user needs to see typing immediately setQuery(value); // This update is NON-URGENT β€” can be delayed without feeling slow startTransition(() => { // Imagine this filters through 10,000 items const filtered = allItems.filter(item => item.name.toLowerCase().includes(value.toLowerCase()) ); setResults(filtered); }); }; return ( <div> <input value={query} onChange={handleSearch} placeholder="Search..." /> {isPending && <p>Updating results...</p>} <ul> {results.map(item => <li key={item.id}>{item.name}</li>)} </ul> </div> ); }

useDeferredValue β€” Deferring a Value

import { useState, useDeferredValue, memo } from 'react'; function SearchPage() { const [query, setQuery] = useState(''); const deferredQuery = useDeferredValue(query); const isStale = query !== deferredQuery; return ( <div> <input value={query} onChange={e => setQuery(e.target.value)} /> <div style={{ opacity: isStale ? 0.5 : 1 }}> <HeavyResults query={deferredQuery} /> </div> </div> ); } // Memoized to benefit from deferring const HeavyResults = memo(function HeavyResults({ query }) { // This component re-renders with the deferred (older) value // while the input stays responsive with the latest value const results = computeExpensiveResults(query); return <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>; });

πŸ“Š useTransition vs useDeferredValue

FeatureuseTransitionuseDeferredValue
What it defersA state updateA value
You controlThe setState callThe consuming component
Best whenYou own the state setterYou receive a value as prop
Returns[isPending, startTransition]Deferred value
Use caseWrapping your own setStateWrapping prop passed to child

11. Custom Hooks β€” Building Your Own

What Are Custom Hooks?

Custom hooks are JavaScript functions whose names start with use and that call other hooks. They let you extract and reuse stateful logic.

The Convention

// Custom hooks MUST start with "use" // This tells React and ESLint that this function follows the Rules of Hooks // βœ… Valid custom hook names: function useWindowSize() { ... } function useLocalStorage(key) { ... } function useFetch(url) { ... } function useAuth() { ... } // ❌ NOT hooks (won't get hook rule checking): function getWindowSize() { ... } function windowSizeHook() { ... }

Custom Hook Example 1: useLocalStorage

import { useState, useEffect } from 'react'; function useLocalStorage(key, initialValue) { // Lazy initialization β€” read from localStorage on first render const [storedValue, setStoredValue] = useState(() => { try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; } catch (error) { console.error(`Error reading localStorage key "${key}":`, error); return initialValue; } }); // Sync to localStorage whenever value changes useEffect(() => { try { window.localStorage.setItem(key, JSON.stringify(storedValue)); } catch (error) { console.error(`Error writing localStorage key "${key}":`, error); } }, [key, storedValue]); return [storedValue, setStoredValue]; } // Usage β€” works just like useState, but persists to localStorage! function App() { const [name, setName] = useLocalStorage('userName', ''); const [darkMode, setDarkMode] = useLocalStorage('darkMode', false); return ( <div> <input value={name} onChange={e => setName(e.target.value)} /> <button onClick={() => setDarkMode(d => !d)}> Toggle Dark Mode ({darkMode ? 'ON' : 'OFF'}) </button> {/* Values persist across page refreshes! */} </div> ); }

Custom Hook Example 2: useFetch

import { useState, useEffect } from 'react'; function useFetch(url, options = {}) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; const controller = new AbortController(); async function fetchData() { setLoading(true); setError(null); try { const response = await fetch(url, { ...options, signal: controller.signal, }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); if (!cancelled) { setData(result); } } catch (err) { if (!cancelled && err.name !== 'AbortError') { setError(err.message); } } finally { if (!cancelled) { setLoading(false); } } } fetchData(); return () => { cancelled = true; controller.abort(); }; }, [url]); return { data, loading, error }; } // Usage: function UserList() { const { data: users, loading, error } = useFetch('/api/users'); if (loading) return <p>Loading...</p>; if (error) return <p>Error: {error}</p>; return ( <ul> {users.map(user => ( <li key={user.id}>{user.name}</li> ))} </ul> ); }

Custom Hook Example 3: useDebounce

import { useState, useEffect } from 'react'; function useDebounce(value, delay = 300) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => { setDebouncedValue(value); }, delay); return () => clearTimeout(timer); }, [value, delay]); return debouncedValue; } // Usage: function SearchBar() { const [query, setQuery] = useState(''); const debouncedQuery = useDebounce(query, 500); // Wait 500ms useEffect(() => { if (debouncedQuery) { // This only fires 500ms after the user stops typing searchAPI(debouncedQuery); } }, [debouncedQuery]); return <input value={query} onChange={e => setQuery(e.target.value)} />; }

Custom Hook Example 4: useToggle

import { useState, useCallback } from 'react'; function useToggle(initialValue = false) { const [value, setValue] = useState(initialValue); const toggle = useCallback(() => setValue(v => !v), []); const setTrue = useCallback(() => setValue(true), []); const setFalse = useCallback(() => setValue(false), []); return { value, toggle, setTrue, setFalse }; } // Usage: function Modal() { const { value: isOpen, toggle, setFalse: close } = useToggle(); return ( <div> <button onClick={toggle}>Toggle Modal</button> {isOpen && ( <div className="modal"> <p>Modal content</p> <button onClick={close}>Close</button> </div> )} </div> ); }

Custom Hook Example 5: useOnClickOutside

import { useEffect, useRef } from 'react'; function useOnClickOutside(ref, handler) { useEffect(() => { const listener = (event) => { // Do nothing if clicking ref's element or its descendants if (!ref.current || ref.current.contains(event.target)) { return; } handler(event); }; document.addEventListener('mousedown', listener); document.addEventListener('touchstart', listener); return () => { document.removeEventListener('mousedown', listener); document.removeEventListener('touchstart', listener); }; }, [ref, handler]); } // Usage: function Dropdown() { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); useOnClickOutside(dropdownRef, () => setIsOpen(false)); return ( <div ref={dropdownRef}> <button onClick={() => setIsOpen(o => !o)}>Menu</button> {isOpen && ( <ul className="dropdown-menu"> <li>Option 1</li> <li>Option 2</li> <li>Option 3</li> </ul> )} </div> ); }

12. Common Mistakes & Gotchas

Mistake 1: The Stale Closure

function Timer() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { // ❌ BUG: count is always 0 (stale closure) setCount(count + 1); }, 1000); return () => clearInterval(id); }, []); // Empty deps β€” count is captured as 0 forever // βœ… FIX: Use functional update useEffect(() => { const id = setInterval(() => { setCount(prev => prev + 1); // prev is always fresh }, 1000); return () => clearInterval(id); }, []); return <p>{count}</p>; }

Mistake 2: Object/Array in Dependency Array

function Component({ config }) { // ❌ This effect runs EVERY render because { page: 1 } !== { page: 1 } useEffect(() => { fetchData(config); }, [config]); // Object reference changes every render! // βœ… FIX 1: Depend on specific primitive values useEffect(() => { fetchData({ page: config.page, limit: config.limit }); }, [config.page, config.limit]); // βœ… FIX 2: Memoize the config in the parent // const config = useMemo(() => ({ page, limit }), [page, limit]); // βœ… FIX 3: Stringify (last resort) useEffect(() => { fetchData(config); }, [JSON.stringify(config)]); }

Mistake 3: Infinite Loop with useEffect

// ❌ Infinite loop β€” effect changes the dependency it watches function Component() { const [data, setData] = useState([]); useEffect(() => { setData([...data, 'new item']); // Changes data, which triggers effect again! }, [data]); // βœ… FIX: Use functional update useEffect(() => { setData(prev => [...prev, 'new item']); }, []); // Run once }

Mistake 4: Forgetting Cleanup

// ❌ Memory leak β€” listener is never removed useEffect(() => { window.addEventListener('scroll', handleScroll); }, []); // βœ… Always clean up useEffect(() => { window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, []);

Mistake 5: useEffect for Derived State

// ❌ Unnecessary β€” adds an extra render cycle const [firstName, setFirstName] = useState('John'); const [lastName, setLastName] = useState('Doe'); const [fullName, setFullName] = useState(''); useEffect(() => { setFullName(`${firstName} ${lastName}`); }, [firstName, lastName]); // βœ… Just compute it during render const fullName = `${firstName} ${lastName}`;

13. Practice Problems

πŸ§ͺ Problem 1: useCounter Hook

Create a custom useCounter hook with increment, decrement, reset, and set functions.

<details> <summary>βœ… Solution</summary>
import { useState, useCallback } from 'react'; function useCounter(initialValue = 0, { min = -Infinity, max = Infinity } = {}) { const [count, setCount] = useState(initialValue); const increment = useCallback(() => { setCount(prev => Math.min(prev + 1, max)); }, [max]); const decrement = useCallback(() => { setCount(prev => Math.max(prev - 1, min)); }, [min]); const reset = useCallback(() => { setCount(initialValue); }, [initialValue]); const set = useCallback((value) => { setCount(typeof value === 'function' ? value : Math.min(Math.max(value, min), max)); }, [min, max]); return { count, increment, decrement, reset, set }; } // Usage: function App() { const { count, increment, decrement, reset } = useCounter(0, { min: 0, max: 10 }); return ( <div> <p>Count: {count}</p> <button onClick={increment}>+</button> <button onClick={decrement}>-</button> <button onClick={reset}>Reset</button> </div> ); }
</details>

πŸ§ͺ Problem 2: useMediaQuery Hook

Create a hook that returns whether a CSS media query matches.

<details> <summary>βœ… Solution</summary>
import { useState, useEffect } from 'react'; function useMediaQuery(query) { const [matches, setMatches] = useState(() => { return window.matchMedia(query).matches; }); useEffect(() => { const mediaQuery = window.matchMedia(query); const handleChange = (event) => { setMatches(event.matches); }; mediaQuery.addEventListener('change', handleChange); // Sync initial value setMatches(mediaQuery.matches); return () => { mediaQuery.removeEventListener('change', handleChange); }; }, [query]); return matches; } // Usage: function ResponsiveLayout() { const isMobile = useMediaQuery('(max-width: 768px)'); const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); return ( <div> {isMobile ? <MobileNav /> : <DesktopNav />} <p>Dark mode: {isDarkMode ? 'yes' : 'no'}</p> <p>Reduced motion: {prefersReducedMotion ? 'yes' : 'no'}</p> </div> ); }
</details>

πŸ§ͺ Problem 3: Multi-Step Form with useReducer

Build a multi-step form wizard (3 steps) with useReducer managing the form state.

<details> <summary>βœ… Solution</summary>
import { useReducer } from 'react'; const initialState = { step: 1, data: { name: '', email: '', phone: '', address: '', city: '', plan: 'basic', }, errors: {}, submitted: false, }; function formReducer(state, action) { switch (action.type) { case 'UPDATE_FIELD': return { ...state, data: { ...state.data, [action.field]: action.value }, errors: { ...state.errors, [action.field]: undefined }, }; case 'NEXT_STEP': return { ...state, step: Math.min(state.step + 1, 3) }; case 'PREV_STEP': return { ...state, step: Math.max(state.step - 1, 1) }; case 'SET_ERRORS': return { ...state, errors: action.errors }; case 'SUBMIT': return { ...state, submitted: true }; case 'RESET': return initialState; default: throw new Error(`Unknown action: ${action.type}`); } } function MultiStepForm() { const [state, dispatch] = useReducer(formReducer, initialState); const { step, data, errors, submitted } = state; const updateField = (field, value) => { dispatch({ type: 'UPDATE_FIELD', field, value }); }; const validateStep = () => { const newErrors = {}; if (step === 1) { if (!data.name.trim()) newErrors.name = 'Name is required'; if (!data.email.includes('@')) newErrors.email = 'Valid email required'; } if (step === 2) { if (!data.phone.trim()) newErrors.phone = 'Phone is required'; if (!data.address.trim()) newErrors.address = 'Address is required'; } dispatch({ type: 'SET_ERRORS', errors: newErrors }); return Object.keys(newErrors).length === 0; }; const handleNext = () => { if (validateStep()) dispatch({ type: 'NEXT_STEP' }); }; const handleSubmit = () => { if (validateStep()) { console.log('Submitted:', data); dispatch({ type: 'SUBMIT' }); } }; if (submitted) { return ( <div style={{ textAlign: 'center', padding: '40px' }}> <h2>βœ… Form Submitted!</h2> <pre>{JSON.stringify(data, null, 2)}</pre> <button onClick={() => dispatch({ type: 'RESET' })}>Start Over</button> </div> ); } const inputStyle = (field) => ({ width: '100%', padding: '10px', margin: '4px 0 12px', border: `2px solid ${errors[field] ? 'red' : '#ddd'}`, borderRadius: '6px', fontSize: '16px', boxSizing: 'border-box', }); return ( <div style={{ maxWidth: '500px', margin: '20px auto', padding: '20px' }}> <div style={{ display: 'flex', justifyContent: 'center', marginBottom: '24px' }}> {[1, 2, 3].map(s => ( <div key={s} style={{ width: '40px', height: '40px', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: s <= step ? '#4CAF50' : '#ddd', color: s <= step ? '#fff' : '#666', fontWeight: 'bold', margin: '0 10px', }}>{s}</div> ))} </div> {step === 1 && ( <div> <h2>Step 1: Personal Info</h2> <label>Name</label> <input style={inputStyle('name')} value={data.name} onChange={e => updateField('name', e.target.value)} /> {errors.name && <p style={{ color: 'red' }}>{errors.name}</p>} <label>Email</label> <input style={inputStyle('email')} value={data.email} type="email" onChange={e => updateField('email', e.target.value)} /> {errors.email && <p style={{ color: 'red' }}>{errors.email}</p>} </div> )} {step === 2 && ( <div> <h2>Step 2: Contact Info</h2> <label>Phone</label> <input style={inputStyle('phone')} value={data.phone} onChange={e => updateField('phone', e.target.value)} /> {errors.phone && <p style={{ color: 'red' }}>{errors.phone}</p>} <label>Address</label> <input style={inputStyle('address')} value={data.address} onChange={e => updateField('address', e.target.value)} /> {errors.address && <p style={{ color: 'red' }}>{errors.address}</p>} <label>City</label> <input style={inputStyle('city')} value={data.city} onChange={e => updateField('city', e.target.value)} /> </div> )} {step === 3 && ( <div> <h2>Step 3: Choose Plan</h2> {['basic', 'pro', 'enterprise'].map(plan => ( <label key={plan} style={{ display: 'block', padding: '15px', margin: '8px 0', border: `2px solid ${data.plan === plan ? '#4CAF50' : '#ddd'}`, borderRadius: '8px', cursor: 'pointer', backgroundColor: data.plan === plan ? '#e8f5e9' : '#fff', }}> <input type="radio" value={plan} checked={data.plan === plan} onChange={e => updateField('plan', e.target.value)} /> {' '}{plan.charAt(0).toUpperCase() + plan.slice(1)} </label> ))} </div> )} <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '20px' }}> <button onClick={() => dispatch({ type: 'PREV_STEP' })} disabled={step === 1} style={{ padding: '10px 24px', fontSize: '16px' }}> Back </button> {step < 3 ? ( <button onClick={handleNext} style={{ padding: '10px 24px', fontSize: '16px', backgroundColor: '#4CAF50', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' }}> Next </button> ) : ( <button onClick={handleSubmit} style={{ padding: '10px 24px', fontSize: '16px', backgroundColor: '#2196F3', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer' }}> Submit </button> )} </div> </div> ); } export default MultiStepForm;
</details>

14. Interview Questions

❓ Q1: What are React hooks? Why were they introduced?

Answer: Hooks are functions that let you use React features (state, lifecycle, context) in function components. They were introduced in React 16.8 to solve three problems:

  1. Hard to reuse stateful logic β€” HOCs and render props created wrapper hell. Custom hooks allow clean extraction and sharing of logic.
  2. Complex components β€” Related logic was split across lifecycle methods. Hooks keep related code together.
  3. Classes are confusing β€” this binding, constructor, super(props) β€” all friction. Functions are simpler.

❓ Q2: Explain the useEffect dependency array. What happens with [], [dep], or nothing?

Answer:

  • No array (useEffect(() => {...})) β€” Runs after EVERY render. Rarely correct.
  • Empty array (useEffect(() => {...}, [])) β€” Runs ONCE after mount. Cleanup runs on unmount.
  • With dependencies (useEffect(() => {...}, [a, b])) β€” Runs when a or b change (using Object.is comparison). Cleanup runs before re-running and on unmount.

React uses the dependency array to decide if the effect needs to re-run. If all dependencies are the same as last render (by reference), the effect is skipped.


❓ Q3: What is a stale closure in React hooks? How do you fix it?

Answer: A stale closure occurs when a function inside a hook captures an old value via JavaScript closure and never sees the updated value.

// Bug β€” count is always 0 inside the interval useEffect(() => { const id = setInterval(() => console.log(count), 1000); return () => clearInterval(id); }, []); // count captured as 0 forever // Fix 1: Add count to deps (but this resets the interval every change) // Fix 2: Use a ref to hold the latest value // Fix 3: Use functional updates β€” setCount(prev => prev + 1)

❓ Q4: What's the difference between useMemo and useCallback?

Answer:

  • useMemo(() => computeValue(), deps) β€” Caches a computed value
  • useCallback(() => fn(), deps) β€” Caches a function reference

useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).

Use useMemo for expensive calculations. Use useCallback when passing callbacks to React.memo-wrapped children to prevent unnecessary re-renders.


❓ Q5: What are the rules of hooks? Why do they exist?

Answer: Two rules:

  1. Only call hooks at the top level β€” not in conditions, loops, or nested functions
  2. Only call hooks from React functions β€” components or custom hooks

They exist because React tracks hooks by call order in a linked list. If you call hooks conditionally, the order changes between renders, and React assigns the wrong state to the wrong hook.


❓ Q6: How do you share stateful logic between components?

Answer: With custom hooks. Extract the shared logic into a function starting with use:

function useOnlineStatus() { const [isOnline, setIsOnline] = useState(true); useEffect(() => { const handler = () => setIsOnline(navigator.onLine); window.addEventListener('online', handler); window.addEventListener('offline', handler); return () => { window.removeEventListener('online', handler); window.removeEventListener('offline', handler); }; }, []); return isOnline; } // Any component can now use this: function StatusBar() { const online = useOnlineStatus(); ... } function SaveButton() { const online = useOnlineStatus(); ... }

Each component gets its own copy of the state β€” custom hooks share logic, not state.


❓ Q7: What's the difference between useEffect and useLayoutEffect?

Answer:

  • useEffect runs asynchronously after paint β€” the user sees the DOM update before the effect runs
  • useLayoutEffect runs synchronously before paint β€” blocks the browser from painting until done

Use useLayoutEffect when you need to measure/modify DOM before the user sees it (e.g., tooltip positioning). Use useEffect for everything else (data fetching, subscriptions).


❓ Q8: What is useRef and how is it different from state?

Answer: useRef returns a persistent mutable container ({ current: value }). Key differences from state:

  1. Changing .current does NOT trigger a re-render
  2. Updates are synchronous (immediately available)
  3. Value persists across renders like state

Use for: DOM element access, storing interval/timeout IDs, tracking previous values, any mutable value that shouldn't trigger re-renders.


❓ Q9: What is state batching? How did it change in React 18?

Answer: Batching means React groups multiple state updates into a single re-render for performance.

React 17: Only batched inside React event handlers. Updates in setTimeout, fetch, native events caused multiple re-renders.

React 18: Automatic batching everywhere β€” event handlers, timeouts, promises, native events all get batched into single re-renders. Opt out with flushSync() if needed.


❓ Q10: When would you use useReducer over useState?

Answer: Use useReducer when:

  • State logic is complex with many sub-values
  • Next state depends on previous state in complex ways
  • Multiple actions can modify the same state
  • You want testable, centralized state logic
  • State transitions resemble a state machine

useReducer makes state updates more predictable and debuggable because all logic lives in one pure function.


15. "What's the Output?" Challenges

Challenge 1

function App() { const [count, setCount] = useState(0); useEffect(() => { console.log('effect', count); return () => console.log('cleanup', count); }, [count]); console.log('render', count); return <button onClick={() => setCount(1)}>Click</button>; } // What logs on mount? What logs when button is clicked?
<details> <summary>Answer</summary>

Mount:

render 0 effect 0

Click (count goes to 1):

render 1 cleanup 0 ← cleanup runs with OLD count value (closure!) effect 1

The render happens first, then the previous effect's cleanup runs (with the old closure value), then the new effect runs.

</details>

Challenge 2

function App() { const [count, setCount] = useState(0); useEffect(() => { document.title = `Count: ${count}`; }); return ( <button onClick={() => { setCount(count + 1); setCount(count + 1); setCount(count + 1); }}> {count} </button> ); } // After clicking once, what is count and document title?
<details> <summary>Answer</summary>

count = 1, document.title = "Count: 1"

All three setCount(count + 1) use the same stale count (0), so all three are setCount(1). React batches them into one update.

If you wanted count to be 3, use setCount(prev => prev + 1).

</details>

Challenge 3

function App() { const renderCount = useRef(0); const [name, setName] = useState(''); renderCount.current += 1; useEffect(() => { console.log(`Rendered ${renderCount.current} times`); }); return <input value={name} onChange={e => setName(e.target.value)} />; } // You type "Hi". How many times does the effect log, and what values?
<details> <summary>Answer</summary>

Logs 3 times:

Rendered 1 times (mount) Rendered 2 times (typed "H") Rendered 3 times (typed "i")

Each keystroke triggers a re-render via setName. The ref increments synchronously during render. The effect runs after each render (no dependency array = runs every time).

</details>

Challenge 4

function App() { const [items, setItems] = useState([1, 2, 3]); useEffect(() => { console.log('Effect ran'); }, [items]); const handleClick = () => { setItems([1, 2, 3]); // Same content, but new array! }; return <button onClick={handleClick}>Click</button>; } // Does the effect run when you click?
<details> <summary>Answer</summary>

YES, the effect runs!

[1, 2, 3] !== [1, 2, 3] because arrays are compared by reference, not by value. setItems([1, 2, 3]) creates a new array object, which is a different reference, so React re-renders and the effect sees a "changed" dependency.

To prevent this, you could use useMemo to memoize the array, or compare manually.

</details>

🎯 Module 02 Summary

You now understand:

  • βœ… Why hooks exist and what problems they solve
  • βœ… The Rules of Hooks (and WHY they exist β€” linked list!)
  • βœ… useState deep dive (batching, lazy init, functional updates)
  • βœ… useEffect (timing, cleanup, closures, dependency array)
  • βœ… useRef (DOM access, mutable values, no re-renders)
  • βœ… useContext (context creation, Provider, consumer patterns)
  • βœ… useReducer (complex state, actions, reducers)
  • βœ… useMemo and useCallback (when to use, when NOT to)
  • βœ… useLayoutEffect vs useEffect
  • βœ… React 18 hooks (useId, useTransition, useDeferredValue)
  • βœ… Custom hooks (patterns, conventions, real examples)

Next up: Module 03: Advanced Component Patterns β€” where we'll learn the patterns used in production codebases and open-source libraries. β†’