Hooks Deep Dive
Hooks are the heart of modern React. They let you add state, side effects, and all kinds of superpowers to your components â without writing a single class. But they come with rules and gotchas that trip up even experienced developers.
What Are Hooks and Why Did They Replace Classes?
Before hooks, the only way to have state or lifecycle methods was with class components:
class Counter extends React.Component {
state = { count: 0 };
componentDidMount() {
document.title = `Count: ${this.state.count}`;
}
componentDidUpdate() {
document.title = `Count: ${this.state.count}`;
}
render() {
return (
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
{this.state.count}
</button>
);
}
}The problems with classes:
- Related logic was split across methods â setting the title happens in both
componentDidMountANDcomponentDidUpdate - Unrelated logic was crammed together â
componentDidMountmight set a title AND add an event listener AND fetch data thiskeyword was confusing â endless bugs from forgetting to bind methods- Hard to reuse stateful logic between components
Hooks solve all of this. The same component with hooks:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
}Related logic lives together. No this. No classes. Clean and readable.
useState â Your First Hook
useState gives a component a piece of memory that survives re-renders.
Think of it like a sticky note on your desk. When someone asks "what's the count?", you look at the sticky note. When the count changes, you cross out the old number and write the new one. The sticky note persists â even when everything else on your desk gets cleaned up.
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}useState(0)â "Start with 0 on my sticky note"countâ "Read the current value"setCount(count + 1)â "Cross out the old value, write the new one, then re-render"
Functional Updates
When the new state depends on the old state, use the functional form:
// Can be buggy if called multiple times quickly
setCount(count + 1);
// Always safe â React gives you the latest value
setCount(prev => prev + 1);Common Mistake: Calling
setCount(count + 1)multiple times in the same handler doesn't increment multiple times âcountis a snapshot from the current render. UsesetCount(prev => prev + 1)instead.
useEffect â Doing Things After Render
useEffect lets you run side effects â things that happen outside of rendering, like fetching data, setting up event listeners, or updating the document title.
Think of it like a post-it reminder: "After you finish painting this room (rendering), don't forget to also do this other thing."
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
if (!user) return <p>Loading...</p>;
return <h1>{user.name}</h1>;
}The Dependency Array â Your Effect's "Watch List"
The second argument to useEffect controls when the effect runs:
// Runs after EVERY render
useEffect(() => {
console.log('I run every time');
});
// Runs ONCE â after the first render only
useEffect(() => {
console.log('I run once, like componentDidMount');
}, []);
// Runs when `userId` changes
useEffect(() => {
console.log(`Fetching user ${userId}`);
}, [userId]);| Dependency Array | When It Runs |
|---|---|
| Not provided | After every render |
[] (empty) | Once, after the first render |
[a, b] | After any render where a or b changed |
Cleanup â Tidying Up After Yourself
Effects can return a cleanup function. React runs it before the effect runs again, and when the component unmounts.
useEffect(() => {
const handler = (e) => console.log('Mouse:', e.clientX, e.clientY);
window.addEventListener('mousemove', handler);
return () => {
window.removeEventListener('mousemove', handler);
};
}, []);Think of it like this: before you put up new wallpaper, you need to peel off the old wallpaper first.
Remember: If your effect sets up a subscription, timer, or event listener â always return a cleanup function. Otherwise you'll have memory leaks.
useEffect vs. useLayoutEffect
Both run after render, but with one critical difference in timing:
- React renders your component (calculates the new UI)
- React updates the DOM
useLayoutEffectruns (synchronously, before the browser paints)- The browser paints pixels on screen
useEffectruns (asynchronously, after the browser paints)
Think of it like remodeling a room:
useLayoutEffect= measuring and adjusting furniture placement before anyone walks in. The room looks right from the start.useEffect= adjusting furniture after guests have already seen the room. They might notice a flicker.
// useLayoutEffect: Measure DOM before paint
function Tooltip({ targetRef, children }) {
const [position, setPosition] = useState({ top: 0, left: 0 });
const tooltipRef = useRef(null);
useLayoutEffect(() => {
const rect = targetRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + 8,
left: rect.left,
});
}, [targetRef]);
return (
<div ref={tooltipRef} style={{ position: 'fixed', ...position }}>
{children}
</div>
);
}When to use useLayoutEffect: Only when you need to measure or mutate the DOM before the user sees the result (tooltips, animations, scroll position restoration). For everything else, use useEffect.
Common Mistake: Reaching for
useLayoutEffectby default. It blocks the browser from painting, so overusing it makes your app feel sluggish. 99% of the time,useEffectis the right choice.
The Stale Closure Trap
This is the single most common hooks bug. It happens when a function "remembers" an old value instead of the current one.
The Broken Timer
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // Always logs 0!
setCount(count + 1); // Always sets to 1!
}, 1000);
return () => clearInterval(id);
}, []);
return <p>Count: {count}</p>;
}What's happening? The effect runs once (empty [] dependency array). The function inside setInterval captures count from that first render â which is 0. It never gets a fresh value. It's stuck reading an old "photograph" of count.
Think of it like taking a photo of a whiteboard and reading from the photo instead of looking at the actual whiteboard. Even when someone updates the whiteboard, your photo still shows the old content.
Fix 1: Functional Update (Best Fix)
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []);You don't read count at all. You tell React "take whatever the current value is and add 1." No stale closure.
Fix 2: useRef for Latest Value
function Timer() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current);
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <p>Count: {count}</p>;
}useRef creates a mutable box that always holds the latest value. The closure reads from the box, not from a snapshot.
Fix 3: Include in Dependencies
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, [count]);This works but creates and destroys the interval on every tick â usually not what you want.
Remember: When a function inside
useEffectreads a state variable but the dependency array doesn't include it, you have a stale closure. Fix it with functional updates, refs, or correct dependencies.
Interview Q&A:
Q: What is a stale closure in React hooks?
A stale closure happens when a function created inside useEffect (or useCallback) captures a state variable from an earlier render and never gets a fresh value. The most common fix is using the functional update form of setState (e.g., setCount(prev => prev + 1)), which doesn't depend on the closure's captured value.
useRef â More Than Just DOM Access
useRef gives you a mutable box that persists across renders without causing re-renders when it changes.
It has two main uses:
Use 1: Accessing DOM Elements
function FocusInput() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<div>
<input ref={inputRef} placeholder="Click the button to focus me" />
<button onClick={handleClick}>Focus Input</button>
</div>
);
}Use 2: Storing Mutable Values That Don't Trigger Re-renders
Think of useRef as a pocket on your component. You can put anything in the pocket and take it out later. Putting something in or taking it out doesn't cause the component to re-render.
function StopWatch() {
const [elapsed, setElapsed] = useState(0);
const intervalRef = useRef(null);
function start() {
intervalRef.current = setInterval(() => {
setElapsed(prev => prev + 1);
}, 1000);
}
function stop() {
clearInterval(intervalRef.current);
}
return (
<div>
<p>Seconds: {elapsed}</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}We store the interval ID in a ref because:
- We need it to persist across renders (so
stopcan clear it) - Changing it shouldn't cause a re-render
| useState | useRef | |
|---|---|---|
| Causes re-render on change | Yes | No |
| Persists across renders | Yes | Yes |
| Best for | Values shown in the UI | Timers, DOM elements, mutable values behind the scenes |
Common Mistake: Using
useStatefor values that don't affect the UI (like timer IDs or previous values). This causes unnecessary re-renders. UseuseRefinstead.
Can You Replace useEffect Completely?
Sometimes! Many things developers put in useEffect can be handled better elsewhere.
// Bad: Using useEffect for derived state
function FilteredList({ items, filter }) {
const [filtered, setFiltered] = useState([]);
useEffect(() => {
setFiltered(items.filter(item => item.type === filter));
}, [items, filter]);
return <List items={filtered} />;
}
// Good: Compute during render â no effect needed
function FilteredList({ items, filter }) {
const filtered = items.filter(item => item.type === filter);
return <List items={filtered} />;
}// Bad: Using useEffect to respond to an event
function SearchPage() {
const [query, setQuery] = useState('');
useEffect(() => {
if (query) logSearch(query);
}, [query]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
// Good: Handle it in the event handler directly
function SearchPage() {
const [query, setQuery] = useState('');
function handleChange(e) {
const value = e.target.value;
setQuery(value);
if (value) logSearch(value);
}
return <input value={query} onChange={handleChange} />;
}You still need useEffect for:
- Fetching data when a component mounts or a dependency changes
- Subscribing to external events (WebSocket, browser events)
- Synchronizing with external systems (third-party libraries, browser APIs)
Remember: If something can be calculated from existing state/props, compute it during render. If something is a response to a user action, put it in the event handler. Only use
useEffectfor synchronizing with things outside of React.
useReducer â When useState Isn't Enough
When your state logic gets complex â multiple related values, actions that depend on previous state â useState starts feeling messy. That's when useReducer shines.
Think of it like a restaurant order system. Instead of the waiter going into the kitchen and cooking (directly setting state), they write an order slip (dispatch an action), and the kitchen (reducer) decides what to prepare based on the order type.
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return { count: 0 };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { 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>
</div>
);
}A Real-World Example: Data Fetching
Where useReducer really outshines useState:
function reducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return { data: action.data, loading: false, error: null };
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.error };
default:
return state;
}
}
function UserList() {
const [state, dispatch] = useReducer(reducer, {
data: null,
loading: false,
error: null,
});
useEffect(() => {
dispatch({ type: 'FETCH_START' });
fetch('/api/users')
.then(res => res.json())
.then(data => dispatch({ type: 'FETCH_SUCCESS', data }))
.catch(err => dispatch({ type: 'FETCH_ERROR', error: err.message }));
}, []);
if (state.loading) return <p>Loading...</p>;
if (state.error) return <p>Error: {state.error}</p>;
if (!state.data) return null;
return (
<ul>
{state.data.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}With useState, you'd have three separate state variables (loading, error, data) and it'd be easy to set them inconsistently (like setting loading: false but forgetting to clear error). The reducer keeps state transitions explicit and impossible to mess up.
When to Choose useReducer Over useState
| Situation | useState | useReducer |
|---|---|---|
| Single, simple value (toggle, counter) | Yes | Overkill |
| Multiple related state values | Gets messy | Yes |
| Next state depends on previous state | Works but fragile | Yes |
| Complex state transitions | Scattered logic | Centralized |
| Want to test state logic separately | Difficult | Easy â test the reducer function |
Remember:
useStateis for simple, independent pieces of state.useReduceris for state that has related values, complex transitions, or when you want all your state logic in one predictable place.
Chapter Summary
| Hook | Purpose | Analogy |
|---|---|---|
| useState | Add memory to a component | Sticky note on your desk |
| useEffect | Run side effects after render | Post-it reminder: "do this after painting" |
| useLayoutEffect | Run side effects before paint | Adjust furniture before guests see the room |
| useRef | Mutable box that doesn't trigger re-renders | A pocket on your component |
| useReducer | Complex state with explicit transitions | Restaurant order slip system |
The golden rules of hooks:
- Only call hooks at the top level â never inside loops, conditions, or nested functions
- Only call hooks from React function components or custom hooks
- If your effect reads a value, include it in the dependency array (or use a functional update)
- Always clean up subscriptions and timers in the effect's return function