React Hooks Tutorial: useEffect, useCallback, useRef, useMemo Guide 2025
When I first started learning React, I came from an Angular background — and honestly, I was really confused. React felt very different, and I especially struggled to understand how React Hooks work. Things like useEffect
and useCallback
were hard to grasp at first.
That’s why I created this guide — to make it easier for you.
I’ll explain React Hooks using simple examples, real output logs, and step-by-step code — not just dry theory. You’ll learn how each hook works, what happens behind the scenes, and why they behave that way.
This guide also includes common interview questions about hooks that people often get asked — so you’ll be more prepared for technical interviews too.
Contents
- 1 Understanding useEffect
- 1.1 What is useEffect?
- 1.2 Basic Pattern
- 1.3 useEffect with Dependencies
- 1.4 The Three Types of useEffect
- 1.5 Preventing Memory Leaks
- 1.6 #1: Multiple useEffect Execution Order
- 1.7 #2. Cleanup Functions with Console Logs
- 1.8 #3. What’s the execution order when this component mounts and when userId changes?
- 2 Understanding useCallback
- 3 Understanding useMemo
- 4 Understanding useRef
- 5 React Hooks Summary Table
Understanding useEffect
Let’s start with useEffect, which is probably the most important hook you’ll use after useState
.
What is useEffect
?
useEffect
is used for handling side effects in the components. Side effects are things like fetching data, updating the DOM, setting up subscriptions, or cleaning up resources. Basically, anything that happens “outside” of just rendering your component.
Basic Pattern
import React, { useState, useEffect } from 'react';
function MyComponent() {
const [count, setCount] = useState(0);
useEffect(() => {
// This runs after every render
document.title = `Count: ${count}`;
});
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
When React renders this component, it remembers the useEffect
function you gave it. After the DOM is updated and painted to the screen, React calls your effect function.
So the sequence is: render → update DOM → run effects.
Every time the component re-renders (like when count changes), React will run this effect again. That’s why the document title updates every time you click the button.
useEffect
with Dependencies
Running effects after every render can be expensive. Let’s say you’re fetching data from an API – you don’t want to do that on every single render! This is where the dependency array comes in.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// This only runs when userId changes
async function fetchUser() {
setLoading(true);
try {
const response = await fetch(`/api/users/${userId}`);
const userData = await response.json();
setUser(userData);
} catch (error) {
console.error('Failed to fetch user:', error);
} finally {
setLoading(false);
}
}
fetchUser();
}, [userId]); // This is the dependency array
if (loading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
React compares the current dependency array [userId]
with the previous one. If userId hasn’t changed, React skips running the effect. If it has changed, React runs the effect. This is called “shallow comparison” – React checks if the values are the same using ===
.
The Three Types of useEffect
1. Effect with no dependencies (runs after every render)
useEffect(() => {
console.log('This runs after every render');
});
2. Effect with dependencies (runs only when dependencies change)
useEffect(() => {
console.log('This runs only when count changes');
}, [count]);
3. Effect with empty dependencies (runs only once after mount)
useEffect(() => {
console.log('This runs only once after component mounts');
}, []);
Preventing Memory Leaks
Sometimes your effects need to clean up after themselves. For example, if you set up a timer or subscribe to events, you need to clean them up when the component unmounts or before the effect runs again.
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
// Cleanup function
return () => {
clearInterval(interval);
};
}, []);
return <div>Timer: {seconds} seconds</div>;
}
When you return a function from useEffect, React treats it as a cleanup function. React will call this cleanup function:
- Before running the effect again (if dependencies changed)
- When the component unmounts
This prevents memory leaks and ensures your app doesn’t keep running timers or maintaining subscriptions for components that no longer exist.
#1: Multiple useEffect Execution Order
Look at this code. When the component first mounts, what will be printed in the console? Then what happens when you click the button?
function TrickyComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('John');
console.log('1. Component render start');
useEffect(() => {
console.log('2. Effect A runs - no dependencies');
});
useEffect(() => {
console.log('3. Effect B runs - count dependency:', count);
}, [count]);
useEffect(() => {
console.log('4. Effect C runs - empty dependency');
}, []);
useEffect(() => {
console.log('5. Effect D runs - name dependency:', name);
}, [name]);
console.log('6. Component render end');
return (
<div>
<p>Count: {count}</p>
<p>Name: {name}</p>
<button onClick={() => setCount(count + 1)}>
Increment Count
</button>
<button onClick={() => setName('Jane')}>
Change Name
</button>
</div>
);
}
When component first mounts:
1. Component render start
6. Component render end
2. Effect A runs - no dependencies
3. Effect B runs - count dependency: 0
4. Effect C runs - empty dependency
5. Effect D runs - name dependency: John
When you click “Increment Count” button:
1. Component render start
6. Component render end
2. Effect A runs - no dependencies
3. Effect B runs - count dependency: 1
When you click “Change Name” button:
1. Component render start
6. Component render end
2. Effect A runs - no dependencies
5. Effect D runs - name dependency: Jane
React runs effects in the order they appear in your code, but only runs them if their dependencies have changed. Effect A runs every time because it has no dependency array. Effect C only runs once because it has an empty dependency array.
#2. Cleanup Functions with Console Logs
What will be printed when this component mounts, when count changes, and when it unmounts?
function CleanupComponent() {
const [count, setCount] = useState(0);
console.log('Component rendering with count:', count);
useEffect(() => {
console.log('Effect 1: Setting up with count:', count);
return () => {
console.log('Effect 1: Cleaning up with count:', count);
};
}, [count]);
useEffect(() => {
console.log('Effect 2: Setting up timer');
const timer = setInterval(() => {
console.log('Timer tick');
}, 1000);
return () => {
console.log('Effect 2: Cleaning up timer');
clearInterval(timer);
};
}, []);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
When component first mounts:
Component rendering with count: 0
Effect 1: Setting up with count: 0
Effect 2: Setting up timer
Timer tick (every second)
When you click button (count becomes 1):
Component rendering with count: 1
Effect 1: Cleaning up with count: 0
Effect 1: Setting up with count: 1
Timer tick (continues)
When component unmounts:
Effect 1: Cleaning up with count: 1
Effect 2: Cleaning up timer
The cleanup function always has access to the values from when the effect was created. That’s why “Cleaning up with count: 0” shows 0, not 1. React runs cleanup before running the effect again.
#3. What’s the execution order when this component mounts and when userId changes?
function ComplexComponent({ userId }) {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
console.log('Component rendering with userId:', userId);
useEffect(() => {
console.log('Effect 1: userId changed to', userId);
setUser(null);
setPosts([]);
setLoading(true);
}, [userId]);
useEffect(() => {
if (!userId) {
console.log('Effect 2: No userId, skipping user fetch');
return;
}
console.log('Effect 2: Fetching user for', userId);
// Simulate API call
setTimeout(() => {
console.log('Effect 2: User fetched for', userId);
setUser({ id: userId, name: `User ${userId}` });
}, 1000);
}, [userId]);
useEffect(() => {
if (!user) {
console.log('Effect 3: No user, skipping posts fetch');
return;
}
console.log('Effect 3: Fetching posts for user', user.id);
// Simulate API call
setTimeout(() => {
console.log('Effect 3: Posts fetched for user', user.id);
setPosts([{ id: 1, title: 'Post 1' }, { id: 2, title: 'Post 2' }]);
setLoading(false);
}, 1000);
}, [user]);
if (loading) return <div>Loading...</div>;
return (
<div>
<h2>{user?.name}</h2>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
When component mounts with userId=123:
Component rendering with userId: 123
Effect 1: userId changed to 123
Effect 2: Fetching user for 123
Effect 3: No user, skipping posts fetch
// Component re-render after setUser, setPost, setLoading
Component rendering with userId: 123
// After 1 second
Effect 2: User fetched for 123
Component rendering with userId: 123 // After setUser
Effect 3: Fetching posts for user 123
// After another 1 second:
Effect 3: Posts fetched for user 123
Component rendering with userId: 123 // After setPosts() and setLoading(false)
When userId changes from 123 to 456:
Component rendering with userId: 456
Effect 1: userId changed to 456
Effect 2: Fetching user for 456
Effect 3: No user, skipping posts fetch
Component rendering with userId: 456
// After 1 second:
Effect 2: User fetched for 456
Component rendering with userId: 456
Effect 3: Fetching posts for user 456
// After another 1 second:
Effect 3: Posts fetched for user 456
Component rendering with userId: 456
Key Takeaways
- Order matters: Effects run in declaration order, and async updates (like
setTimeout
) resolve later, triggering cascading renders. - State updates in effects are batched and cause re-renders after all effects finish executing in a cycle.
- Effects only re-run if dependencies change between renders.
Understanding useCallback
Now let’s talk about useCallback, which is all about performance optimization.
The Problem useCallback Solves
In JavaScript, every time a function is created, it’s a new object in memory. This can cause unnecessary re-renders in React when you pass functions as props to child components.
function Parent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// This creates a new function every render
const handleClick = () => {
console.log('Button clicked!');
};
return (
<div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<ExpensiveChild onClick={handleClick} />
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
const ExpensiveChild = React.memo(({ onClick }) => {
console.log('ExpensiveChild rendered');
return <button onClick={onClick}>Click me</button>;
});
Even though we wrapped ExpensiveChild
with React.memo
, it still re-renders every time the parent renders. This is because handleClick
is a new function every time, so React thinks the props have changed.
function Parent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
const handleClick = useCallback(() => {
console.log('Button clicked!');
}, []);
return (
<div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<ExpensiveChild onClick={handleClick} />
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
useCallback returns a memoized version of the function. React keeps the same function reference across renders unless the dependencies change. Now ExpensiveChild
won’t re-render when you type in the input or increment the count.
useCallback with Dependencies
Sometimes your callback needs to use values from the component scope:
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const searchItems = useCallback(async (searchTerm) => {
if (!searchTerm) {
setResults([]);
return;
}
try {
const response = await fetch(`/api/search?q=${searchTerm}`);
const data = await response.json();
setResults(data);
} catch (error) {
console.error('Search failed:', error);
}
}, []); // No dependencies needed since we pass searchTerm as parameter
const handleSearch = useCallback(() => {
searchItems(query);
}, [query, searchItems]); // Depends on query and searchItems
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<SearchButton onClick={handleSearch} />
<SearchResults results={results} />
</div>
);
}
If you don’t use useCallback
, then handleSearch
and searchItems
will be new function instances on every render.
When NOT to Use useCallback
Here’s an important lesson: don’t use useCallback
everywhere! It’s only useful when:
- You’re passing the function to a child component wrapped in React.memo
- The function is used as a dependency in
useEffect
or other hooks - The function is expensive to create
// DON'T do this - unnecessary optimization
function SimpleComponent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(count + 1);
}, [count]); // This actually makes things worse!
return (
<button onClick={handleClick}>
Count: {count}
</button>
);
}
// DO this instead - simple and clean
function SimpleComponent() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
In first example, we’re trying to “optimize” by memoizing handleClick
— but it’s actually being re-created on every render anyway because:
count
changes every time you clickhandleClick
depends oncount
- So the function is re-created every time
- You gain nothing, but add complexity
#4. Why does this child component keep re-rendering even though we used React.memo?
function ParentComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('John');
console.log('Parent rendering');
const handleClick = useCallback(() => {
console.log('Button clicked with count:', count);
alert(`Count is ${count}`);
}, []); // Wrong dependency array!
return (
<div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<ChildComponent onClick={handleClick} />
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
</div>
);
}
const ChildComponent = React.memo(({ onClick }) => {
console.log('Child rendering');
return <button onClick={onClick}>Click me</button>;
});
What happens when you click the count button:
Parent rendering
Child rendering // Child re-renders because handleClick reference changes
The problem: Even though we used useCallback, the function still changes because count
is used inside but not in the dependency array.
const handleClick = useCallback(() => {
console.log('Button clicked with count:', count);
alert(`Count is ${count}`);
}, [count]); // Include count in dependencies
Understanding useMemo
useMemo
is useCallback
‘s cousin, but instead of memoizing functions, it memoizes values.
The Problem useMemo Solves
Sometimes we have expensive calculations that we don’t want to run on every render:
function ExpensiveComponent({ items }) {
const [filter, setFilter] = useState('');
// This expensive calculation runs on every render
const expensiveValue = items
.filter(item => item.category === 'electronics')
.reduce((sum, item) => sum + item.price, 0);
const filteredItems = items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
);
return (
<div>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter items..."
/>
<p>Total electronics value: ${expensiveValue}</p>
<div>
{filteredItems.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
</div>
);
}
Every time we type in the filter input, the component re-renders, and the expensive calculation runs again even though the items haven’t changed.
import React, { useState, useMemo } from 'react';
function ExpensiveComponent({ items }) {
const [filter, setFilter] = useState('');
// This expensive calculation only runs when items change
const expensiveValue = useMemo(() => {
console.log('Calculating expensive value...');
return items
.filter(item => item.category === 'electronics')
.reduce((sum, item) => sum + item.price, 0);
}, [items]);
// This filtered list only recalculates when items or filter changes
const filteredItems = useMemo(() => {
console.log('Filtering items...');
return items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
);
}, [items, filter]);
return (
<div>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter items..."
/>
<p>Total electronics value: ${expensiveValue}</p>
<div>
{filteredItems.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
</div>
);
}
In this fix, useMemo
runs the function and caches the result. On subsequent renders, if the dependencies haven’t changed, it returns the cached value instead of running the function again.
Understanding useRef
useRef
is unique because it doesn’t trigger re-renders when its value changes. When you use useRef
, React gives you a special object like this:
const myRef = useRef(initialValue);
This object looks like:
{ current: initialValue }
You can read or update the .current
value anytime, and React will not re-render the component. That’s the key.
Accessing DOM Elements
import React, { useRef, useEffect } from 'react';
function FocusInput() {
const inputRef = useRef(null);
useEffect(() => {
// Focus the input when component mounts
inputRef.current.focus();
}, []);
const handleFocus = () => {
inputRef.current.focus();
};
return (
<div>
<input
ref={inputRef}
type="text"
placeholder="This will be focused on mount"
/>
<button onClick={handleFocus}>Focus Input</button>
</div>
);
}
In FocusInput
function, we use useRef
to grab the actual DOM input element. When the component loads, we call:
inputRef.current.focus();
This is just like saying: “Hey browser, find that input box and focus on it.” This is something you can’t easily do with useState
, because state is for data, not for DOM nodes.
Persisting Values Between Renders
function Timer() {
const [seconds, setSeconds] = useState(0);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef(null);
useEffect(() => {
if (isRunning) {
intervalRef.current = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
} else {
clearInterval(intervalRef.current);
}
return () => clearInterval(intervalRef.current);
}, [isRunning]);
const reset = () => {
setSeconds(0);
setIsRunning(false);
};
return (
<div>
<p>Time: {seconds}s</p>
<button onClick={() => setIsRunning(!isRunning)}>
{isRunning ? 'Pause' : 'Start'}
</button>
<button onClick={reset}>Reset</button>
</div>
);
}
In Timer
function, we store the setInterval
ID in intervalRef.current
. Why?
Because:
- We don’t want to re-render every time the timer ID changes.
- But we still need to remember it between renders so we can stop the timer later using
clearInterval()
.
So useRef
is like a “box” where you can store anything — numbers, objects, DOM elements, timers, etc. — and it won’t cause re-renders like useState
does.
What if we never use useRef
:
let intervalId;
useEffect(() => {
if (isRunning) {
intervalId = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
} else {
clearInterval(intervalId);
}
return () => clearInterval(intervalId);
}, [isRunning]);
This will not work reliably because:
intervalId
is re-declared on every render, so it gets reset toundefined
.clearInterval(intervalId)
might not stop the correct timer.- You risk memory leaks, or multiple timers running at once.
#6. Why You Shouldn’t Use useState
for intervalId
const [intervalId, setIntervalId] = useState(null);
useEffect(() => {
if (isRunning) {
const id = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
setIntervalId(id); // 👈 store intervalId in state
} else {
clearInterval(intervalId); // ⚠️ might not have the latest ID here!
}
return () => clearInterval(intervalId);
}, [isRunning]);
React doesn’t immediately update state. setIntervalId(id)
will update it in the next render, but not right away.
That means:
- When you try to call
clearInterval(intervalId)
, theintervalId
might still benull
or an old value. - This makes cleanup unreliable.
- In contrast,
useRef
updates immediately and doesn’t wait for a re-render.
function RefVsStateComponent() {
const [stateCount, setStateCount] = useState(0);
const refCount = useRef(0);
console.log('Component rendering. stateCount:', stateCount, 'refCount:', refCount.current);
const handleStateIncrement = () => {
console.log('State button clicked. Before:', stateCount);
setStateCount(stateCount + 1);
console.log('State button clicked. After setState:', stateCount); // Still old value!
};
const handleRefIncrement = () => {
console.log('Ref button clicked. Before:', refCount.current);
refCount.current = refCount.current + 1;
console.log('Ref button clicked. After:', refCount.current);
};
return (
<div>
<p>State Count: {stateCount}</p>
<p>Ref Count: {refCount.current}</p>
<button onClick={handleStateIncrement}>
Increment State
</button>
<button onClick={handleRefIncrement}>
Increment Ref
</button>
</div>
);
}
When you click “Increment State”:
State button clicked. Before: 0
State button clicked. After setState: 0 // State updates are asynchronous!
Component rendering. stateCount: 1 refCount: 0
When you click “Increment Ref”:
Ref button clicked. Before: 0
Ref button clicked. After: 1
// No re-render happens!
useState
triggers re-renders and updates are asynchronous. useRef
doesn’t trigger re-renders and updates are immediate. The display won’t update for ref changes until something else causes a re-render.
Use useRef
when:
- You need to access or control a DOM element (like
.focus()
) - You want to store something between renders without re-rendering (like an interval ID or a previous value)
React Hooks Summary Table
