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
- What Are Hooks and Why Do They Exist?
- The Rules of Hooks
- useState Deep Dive
- useEffect Deep Dive
- useRef Deep Dive
- useContext Deep Dive
- useReducer Deep Dive
- useMemo and useCallback
- useLayoutEffect
- React 18+ Hooks: useId, useTransition, useDeferredValue
- Custom Hooks β Building Your Own
- Common Mistakes & Gotchas
- Practice Problems
- Interview Questions
- "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 preserved4. 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-renderTwo 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
| Feature | useRef | useState |
|---|---|---|
| Triggers re-render? | β No | β Yes |
| Persists across renders? | β Yes | β Yes |
| Mutable? | β Yes (direct mutation) | β No (use setter) |
| Good for | DOM refs, timers, previous values | UI data that should trigger updates |
| When value changes | Nothing 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 consumersReal-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
| Feature | useState | useReducer |
|---|---|---|
| Best for | Simple, independent state | Complex state with many transitions |
| Update syntax | setState(newValue) | dispatch({ type, payload }) |
| State shape | Single value | Object with multiple fields |
| Related updates | Manual coordination | Single reducer handles all |
| Testability | Inline logic | Pure function, easy to test |
| Debugging | Harder (scattered setState) | Easier (centralized, loggable) |
| Learning curve | Low | Medium |
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 needlesslyDecision 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
| Feature | useEffect | useLayoutEffect |
|---|---|---|
| Timing | After paint (async) | Before paint (sync) |
| Blocks paint? | No | Yes |
| Use for | Data fetching, subscriptions, logging | DOM measurements, preventing visual flicker |
| Performance | Better (non-blocking) | Can cause jank if slow |
| SSR | Works | β οΈ Warning on server (no DOM) |
| Default choice | β Yes | Only 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 positionuseTransition β 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
| Feature | useTransition | useDeferredValue |
|---|---|---|
| What it defers | A state update | A value |
| You control | The setState call | The consuming component |
| Best when | You own the state setter | You receive a value as prop |
| Returns | [isPending, startTransition] | Deferred value |
| Use case | Wrapping your own setState | Wrapping 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.
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>
);
}π§ͺ 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>
);
}π§ͺ 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;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:
- Hard to reuse stateful logic β HOCs and render props created wrapper hell. Custom hooks allow clean extraction and sharing of logic.
- Complex components β Related logic was split across lifecycle methods. Hooks keep related code together.
- Classes are confusing β
thisbinding,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 whenaorbchange (usingObject.iscomparison). 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 valueuseCallback(() => 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:
- Only call hooks at the top level β not in conditions, loops, or nested functions
- 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:
useEffectruns asynchronously after paint β the user sees the DOM update before the effect runsuseLayoutEffectruns 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:
- Changing
.currentdoes NOT trigger a re-render - Updates are synchronous (immediately available)
- 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?Mount:
render 0
effect 0Click (count goes to 1):
render 1
cleanup 0 β cleanup runs with OLD count value (closure!)
effect 1The 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?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).
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?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).
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?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.
π― 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. β