What is useEffect in React?
useEffect is a React Hook used to perform side effects from a functional component.
A component's main responsibility is to calculate and return UI:
const User = () => {
return <h2>User Profile</h2>;
};
But real applications need to do things beyond simply generating JSX. For example:
- Call an API
- Fetch data from a server
- Change the browser document title
- Subscribe to an event
- Start a timer
- Listen for
windowevents - Connect to WebSocket
- Read/write browser storage
- Integrate with a third-party library
- Perform cleanup when a component is removed
- React to changes in props or state
These operations are called side effects.
useEffect provides a way to tell React:
"After React has rendered this component, perform this external operation."
The basic syntax is:
useEffect(() => {
// Side effect
}, []);
You import it from React:
import { useEffect } from "react";
What exactly is a Side Effect?
A side effect is an operation that interacts with something outside the component's rendering calculation.
For example:
const User = ({ name }: { name: string }) => {
document.title = name;
return <h2>{name}</h2>;
};
Changing document.title is a side effect because the component is modifying something outside React's rendered JSX.
Instead, use:
const User = ({ name }: { name: string }) => {
useEffect(() => {
document.title = name;
}, [name]);
return <h2>{name}</h2>;
};
React renders the component first, and then the effect runs.
The Basic Execution Model
A useful way to understand useEffect is:
Component renders
↓
React updates the DOM
↓
Browser paints the UI
↓
useEffect runs
↓
External operation happens
↓
Something may change
↓
Component may render again
For example:
const Counter = () => {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
};
Initially:
count = 0
↓
Component renders
↓
document.title = "Count: 0"
When the button is clicked:
count = 1
↓
Component renders again
↓
React updates UI
↓
Effect runs because count changed
↓
document.title = "Count: 1"
The important point is that the effect does not run during the rendering calculation itself.
useEffect Syntax
The complete form is:
useEffect(() => {
// Effect logic
return () => {
// Cleanup logic
};
}, [dependencies]);
There are three important parts:
useEffect(
effect function,
dependency array
)
The cleanup function is optional.
For example:
useEffect(() => {
console.log("Effect executed");
return () => {
console.log("Cleanup executed");
};
}, [count]);
You can think of it as:
useEffect
│
├── Effect function
│ └── What should happen?
│
└── Dependency array
└── When should it happen again?
The Most Important Rule of useEffect
The dependency array determines when React should re-run the effect.
There are three major forms:
useEffect(() => {
// ...
});
useEffect(() => {
// ...
}, []);
useEffect(() => {
// ...
}, [value]);
These behave differently.
1. useEffect Without a Dependency Array
Example:
useEffect(() => {
console.log("Effect executed");
});
There is no dependency array.
Therefore, the effect runs after every render.
Example:
const Counter = () => {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("Effect executed");
});
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
};
The sequence is:
Initial render
↓
Effect runs
Click
↓
State changes
↓
Component renders
↓
Effect runs
Click
↓
State changes
↓
Component renders
↓
Effect runs
So:
useEffect(() => {
// Runs after every render
});
When is this useful?
This form is less common because it can execute very frequently.
It can be useful when you genuinely need to perform an operation after every render, but you should be careful because expensive work here can hurt performance.
2. useEffect With an Empty Dependency Array
Example:
useEffect(() => {
console.log("Effect executed");
}, []);
An empty dependency array means:
Run this effect after the component's initial mount.
For example:
const Home = () => {
useEffect(() => {
console.log("Home component mounted");
}, []);
return <h2>Home Page</h2>;
};
Conceptually:
Component created
↓
Initial render
↓
DOM updated
↓
Effect runs
↓
No dependency changes
↓
Effect does not run again
This is commonly used for initialization work.
For example:
useEffect(() => {
loadUsers();
}, []);
Or:
useEffect(() => {
document.title = "Home";
}, []);
Or:
useEffect(() => {
localStorage.setItem("visited", "true");
}, []);
Important: Empty Dependency Array Does Not Mean "Only Once" in Every Development Situation
You will often hear:
"
useEffect(..., [])runs only once."
That is a useful simplified description for a mounted component, but there is an important development behavior.
With React Strict Mode in development, React may intentionally run the effect setup and cleanup cycle more than once to help detect unsafe side effects.
For example:
useEffect(() => {
console.log("Effect");
return () => {
console.log("Cleanup");
};
}, []);
You may see development behavior resembling:
Effect
Cleanup
Effect
This does not mean production applications necessarily execute the effect twice.
Therefore, effects should be written so that setup and cleanup are safe and repeatable.
3. useEffect With Dependencies
Example:
useEffect(() => {
console.log("Count changed");
}, [count]);
Now React watches count.
The effect runs:
- After the initial render
- Again when
countchanges
For example:
const Counter = () => {
const [count, setCount] = useState(0);
useEffect(() => {
console.log(`Count is ${count}`);
}, [count]);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
};
The behavior is:
Initial render
count = 0
↓
Effect runs
count = 1
↓
Effect runs
count = 2
↓
Effect runs
count = 3
↓
Effect runs
But if another state changes:
const [count, setCount] = useState(0);
const [name, setName] = useState("");
and the effect is:
useEffect(() => {
console.log(count);
}, [count]);
Changing name does not cause this effect to run again.
count changes → Effect runs
name changes → Effect doesn't run
Multiple Dependencies
You can specify multiple dependencies:
useEffect(() => {
console.log("User or category changed");
}, [userId, categoryId]);
The effect runs when either dependency changes.
For example:
const [userId, setUserId] = useState(1);
const [categoryId, setCategoryId] = useState(10);
useEffect(() => {
console.log("Reload data");
}, [userId, categoryId]);
Behavior:
userId changes
↓
Effect runs
categoryId changes
↓
Effect runs
both change
↓
Effect runs
The important concept is:
The effect is re-executed when at least one dependency has changed since the previous render.
How React Determines Whether a Dependency Changed
React compares dependency values between renders.
For primitive values:
[count]
comparison is straightforward.
For example:
Previous: 10
Current: 10
→ unchanged
Previous: 10
Current: 11
→ changed
For objects and arrays, the situation is different because React compares references.
const user = {
id: 1,
name: "Syed"
};
Creating another object:
const user2 = {
id: 1,
name: "Syed"
};
does not make them the same reference:
user === user2
→ false
Therefore:
useEffect(() => {
console.log("User changed");
}, [user]);
can run when a new object reference is created, even if the object's properties contain the same values.
This becomes very important when working with objects, arrays, and functions.
Using useEffect for API Calls
One of the most common uses of useEffect is fetching data.
Example:
import { useEffect, useState } from "react";
type User = {
id: number;
name: string;
};
const Users = () => {
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
fetch("https://api.example.com/users")
.then(response => response.json())
.then(data => {
setUsers(data);
});
}, []);
return (
<div>
{users.map(user => (
<div key={user.id}>
{user.name}
</div>
))}
</div>
);
};
The overall functionality is:
Component starts
↓
users = []
↓
Initial UI rendered
↓
useEffect runs
↓
API request sent
↓
Server responds
↓
setUsers(data)
↓
State changes
↓
Component renders again
↓
Users displayed
The empty dependency array means the API request is not intentionally repeated every time the component renders.
API Call Based on a Dependency
Suppose the API depends on a user ID.
const UserDetails = ({ userId }: { userId: number }) => {
const [user, setUser] = useState<any>(null);
useEffect(() => {
fetch(`https://api.example.com/users/${userId}`)
.then(response => response.json())
.then(data => {
setUser(data);
});
}, [userId]);
return (
<div>
{user && <h2>{user.name}</h2>}
</div>
);
};
Now the behavior is:
userId = 10
↓
Fetch /users/10
userId = 20
↓
Fetch /users/20
userId = 30
↓
Fetch /users/30
This is an important pattern:
Use the dependency array when the external operation depends on changing component data.
Handling Loading, Error and Data Together
A realistic API effect usually manages more than just the data.
const Users = () => {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const loadUsers = async () => {
try {
setLoading(true);
const response = await fetch(
"https://api.example.com/users"
);
if (!response.ok) {
throw new Error("Failed to load users");
}
const data = await response.json();
setUsers(data);
}
catch (error) {
setError("Unable to load users");
}
finally {
setLoading(false);
}
};
loadUsers();
}, []);
if (loading) {
return <p>Loading...</p>;
}
if (error) {
return <p>{error}</p>;
}
return (
<div>
{users.map(user => (
<div key={user.id}>
{user.name}
</div>
))}
</div>
);
};
The important idea is that useEffect coordinates the external API operation with React state.
useEffect and async
You should not make the effect callback itself directly async like this:
useEffect(async () => {
// ...
}, []);
The reason is that an effect callback is expected to optionally return a cleanup function, whereas an async function returns a Promise.
Instead:
useEffect(() => {
const loadData = async () => {
const response = await fetch("/api/users");
const data = await response.json();
setUsers(data);
};
loadData();
}, []);
This keeps the effect callback synchronous while the asynchronous operation happens inside it.
Cleanup Function
One of the most important features of useEffect is cleanup.
Example:
useEffect(() => {
const timer = setInterval(() => {
console.log("Running...");
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
The effect creates something:
setInterval()
The cleanup removes it:
clearInterval()
Think of it as:
Effect:
"Start something"
Cleanup:
"Stop what you started"
This prevents resources from continuing to run after they are no longer needed.
Why Cleanup Is Important
Suppose you create a timer:
useEffect(() => {
setInterval(() => {
console.log("Hello");
}, 1000);
}, []);
There is no cleanup.
If the component is removed, the interval may continue running.
A better version is:
useEffect(() => {
const timerId = setInterval(() => {
console.log("Hello");
}, 1000);
return () => {
clearInterval(timerId);
};
}, []);
Now React has a way to clean up the resource.
Cleanup When a Component Is Unmounted
Consider:
const Timer = () => {
useEffect(() => {
const id = setInterval(() => {
console.log("Tick");
}, 1000);
return () => {
clearInterval(id);
};
}, []);
return <h2>Timer</h2>;
};
If the component is removed:
{showTimer && <Timer />}
and:
setShowTimer(false);
React removes Timer.
Before the effect is permanently discarded, React runs its cleanup:
Timer component removed
↓
Cleanup executes
↓
clearInterval()
↓
Timer stopped
Cleanup Before an Effect Runs Again
Cleanup is not only for unmounting.
Consider:
useEffect(() => {
console.log(`Listening for ${userId}`);
return () => {
console.log(`Stop listening for ${userId}`);
};
}, [userId]);
If:
userId = 10
then:
Effect starts listening for 10
When userId changes to 20:
Cleanup for 10
↓
Effect for 20
So the lifecycle is conceptually:
Previous effect
↓
Cleanup previous effect
↓
New effect
This is extremely useful for subscriptions.
useEffect With window Events
Suppose you want to listen for browser resize events.
const WindowSize = () => {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return <h2>Width: {width}px</h2>;
};
The effect performs two operations:
Mount
↓
Add resize listener
Cleanup:
Unmount
↓
Remove resize listener
This is a classic useEffect pattern.
Event Listener Based on a Dependency
You can also recreate a subscription when some value changes.
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
console.log(`User ${userId} received message`);
};
window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
};
}, [userId]);
When userId changes:
Remove old listener
↓
Create new listener
↓
New listener uses latest userId
This prevents old subscriptions from remaining active.
useEffect for document.title
A very simple example:
const Counter = () => {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
};
The browser tab title becomes:
Count: 0
Count: 1
Count: 2
Count: 3
This is a good example of synchronizing React state with an external browser API.
useEffect for localStorage
Suppose you want to save a theme preference.
const Theme = () => {
const [theme, setTheme] = useState("light");
useEffect(() => {
localStorage.setItem("theme", theme);
}, [theme]);
return (
<button
onClick={() =>
setTheme(theme === "light" ? "dark" : "light")
}
>
{theme}
</button>
);
};
Every time theme changes:
theme changes
↓
React renders
↓
Effect executes
↓
localStorage updated
Reading From localStorage
You can also initialize state from browser storage.
const Theme = () => {
const [theme, setTheme] = useState("light");
useEffect(() => {
const savedTheme = localStorage.getItem("theme");
if (savedTheme) {
setTheme(savedTheme);
}
}, []);
return <h2>{theme}</h2>;
};
Here the effect reads external data after mounting.
However, for synchronous browser APIs such as localStorage, you can often initialize state directly instead:
const [theme, setTheme] = useState(() => {
return localStorage.getItem("theme") ?? "light";
});
This avoids an initial render with the wrong value.
useEffect With Props
Effects can depend on props.
const Product = ({ productId }: { productId: number }) => {
useEffect(() => {
console.log(`Loading product ${productId}`);
}, [productId]);
return <h2>Product {productId}</h2>;
};
If the parent changes:
<Product productId={10} />
to:
<Product productId={20} />
then the effect runs again.
The pattern is:
Parent changes prop
↓
Child renders with new prop
↓
Dependency changed
↓
Effect executes
useEffect for Authentication
For example, you might need to verify a stored authentication token.
useEffect(() => {
const token = localStorage.getItem("token");
if (token) {
validateToken(token);
}
}, []);
The effect can coordinate authentication with an external API or storage mechanism.
However, authentication logic should generally be organized into reusable services/hooks rather than putting all authentication behavior into one component.
useEffect for WebSocket Connections
WebSockets are a classic example because they require both setup and cleanup.
useEffect(() => {
const socket = new WebSocket(
"wss://example.com/socket"
);
socket.onmessage = event => {
console.log(event.data);
};
return () => {
socket.close();
};
}, []);
The lifecycle is:
Component mounted
↓
WebSocket created
↓
Messages received
↓
Component removed
↓
Cleanup
↓
WebSocket closed
Without cleanup, the connection can remain open unnecessarily.
WebSocket Based on a User ID
useEffect(() => {
const socket = new WebSocket(
`wss://example.com/users/${userId}`
);
socket.onmessage = event => {
console.log(event.data);
};
return () => {
socket.close();
};
}, [userId]);
Now changing userId causes:
Old WebSocket
↓
close()
↓
New WebSocket
↓
Connect using new userId
This is one of the strongest examples of why cleanup and dependencies work together.
useEffect for Subscriptions
Suppose an external library exposes:
subscribe(callback)
unsubscribe(callback)
You can integrate it with React:
useEffect(() => {
const handleUpdate = (data: any) => {
console.log(data);
};
subscribe(handleUpdate);
return () => {
unsubscribe(handleUpdate);
};
}, []);
The general pattern is:
Subscribe
↓
Use external data
↓
Cleanup
↓
Unsubscribe
useEffect With AbortController
When fetching data, cancellation can be important.
useEffect(() => {
const controller = new AbortController();
fetch("/api/users", {
signal: controller.signal
})
.then(response => response.json())
.then(data => {
setUsers(data);
})
.catch(error => {
if (error.name !== "AbortError") {
console.error(error);
}
});
return () => {
controller.abort();
};
}, []);
The overall behavior is:
Effect starts
↓
Create AbortController
↓
API request starts
↓
Component removed / effect replaced
↓
Cleanup executes
↓
Request aborted
This is particularly useful when effects perform network requests that may become irrelevant.
Avoiding Race Conditions in API Requests
Suppose userId changes quickly:
Request A → userId = 10
Request B → userId = 20
If request A takes longer than request B, response A might arrive after response B.
That can result in stale data being displayed.
Using cancellation can help:
useEffect(() => {
const controller = new AbortController();
const loadUser = async () => {
try {
const response = await fetch(
`/api/users/${userId}`,
{
signal: controller.signal
}
);
const data = await response.json();
setUser(data);
}
catch (error: any) {
if (error.name !== "AbortError") {
console.error(error);
}
}
};
loadUser();
return () => {
controller.abort();
};
}, [userId]);
When userId changes:
Cancel request for old user
↓
Start request for new user
This makes the effect's lifecycle much safer.
useEffect and Derived Data
A common mistake is using useEffect for calculations that can be performed during rendering.
For example:
const [firstName, setFirstName] = useState("Syed");
const [lastName, setLastName] = useState("Ahmed");
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
This works, but it is usually unnecessary.
fullName is derived from existing state.
You can simply do:
const fullName = `${firstName} ${lastName}`;
Then:
return <h2>{fullName}</h2>;
This is an important rule:
Don't use
useEffectwhen ordinary rendering logic can calculate the value.
Bad Use of useEffect
Consider:
useEffect(() => {
setFullName(firstName + " " + lastName);
}, [firstName, lastName]);
The sequence becomes:
firstName changes
↓
Render
↓
Effect
↓
setFullName()
↓
Another render
You have introduced an unnecessary render.
Instead:
const fullName = `${firstName} ${lastName}`;
No effect is required.
useEffect Should Not Be Used for Everything
A useful question before using useEffect is:
"Am I synchronizing React with something outside React?"
If the answer is yes, useEffect may be appropriate.
If the answer is no, you may not need it.
For example:
const fullName = `${firstName} ${lastName}`;
No effect.
Filtering:
const activeUsers = users.filter(user => user.active);
No effect.
Mapping:
const names = users.map(user => user.name);
No effect.
Calculating:
const total = price * quantity;
No effect.
But:
document.title = ...
Effect may be appropriate.
fetch(...)
Effect may be appropriate.
window.addEventListener(...)
Effect may be appropriate.
WebSocket connection
Effect may be appropriate.
useEffect and Infinite Loops
One of the most common mistakes is creating an effect that updates a dependency that causes the effect to execute again.
For example:
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
}, [count]);
The sequence is:
count = 0
↓
Effect runs
↓
setCount(1)
↓
Render
↓
count changed
↓
Effect runs
↓
setCount(2)
↓
Render
↓
count changed
↓
Effect runs
↓
...
This can continue indefinitely.
The key problem is:
Effect depends on count
↓
Effect changes count
↓
Dependency changes
↓
Effect runs again
You need to carefully consider whether the state update is actually necessary.
Another Infinite Loop Example
This can also happen when an effect updates state that is recreated on every render.
For example:
const [users, setUsers] = useState<User[]>([]);
const filteredUsers = users.filter(user => user.active);
useEffect(() => {
setFilteredUsers(filteredUsers);
}, [filteredUsers]);
filteredUsers is a new array on every render.
Therefore:
Render
↓
filter() creates new array
↓
Dependency reference changed
↓
Effect runs
↓
setState
↓
Render
↓
filter() creates another array
↓
Dependency reference changed
↓
Effect runs
...
Often the correct solution is to derive the value directly:
const filteredUsers = users.filter(user => user.active);
rather than storing it separately.
Functions as Dependencies
Consider:
const loadUsers = () => {
// load users
};
useEffect(() => {
loadUsers();
}, [loadUsers]);
This can be problematic because functions declared inside a component can receive a new reference on each render.
Conceptually:
Render 1 → loadUsers reference A
Render 2 → loadUsers reference B
Render 3 → loadUsers reference C
React sees different function references.
For this kind of situation, you may restructure the effect:
useEffect(() => {
const loadUsers = async () => {
// API call
};
loadUsers();
}, []);
Or, when appropriate, use useCallback to stabilize a function reference.
useEffect With useCallback
Example:
const loadUser = useCallback(() => {
console.log(userId);
}, [userId]);
useEffect(() => {
loadUser();
}, [loadUser]);
Here useCallback controls the function reference.
The relationship is:
userId changes
↓
useCallback creates new function
↓
loadUser reference changes
↓
useEffect dependency changes
↓
Effect runs
This is useful when a function itself needs to be treated as a dependency.
But don't use useCallback automatically. It should solve a real dependency/reference problem.
useEffect With Object Dependencies
Consider:
const user = {
id: 10,
name: "Syed"
};
useEffect(() => {
console.log(user);
}, [user]);
The object is recreated during rendering.
Therefore the reference may change:
Render 1 → user object A
Render 2 → user object B
Render 3 → user object C
Even though:
id = 10
name = "Syed"
remain the same.
If the effect only depends on the ID, use:
useEffect(() => {
console.log(user.id);
}, [user.id]);
This expresses the actual dependency more precisely.
useEffect for Conditional Logic
You can conditionally execute logic inside the effect.
For example:
useEffect(() => {
if (!userId) {
return;
}
loadUser(userId);
}, [userId]);
The effect itself is still called on every relevant dependency change, but the operation only happens when the condition is satisfied.
This is preferable to conditionally calling the Hook.
Do Not Call useEffect Inside an if
Incorrect:
if (userId) {
useEffect(() => {
loadUser(userId);
}, [userId]);
}
Hooks must be called at the top level of the component.
Correct:
useEffect(() => {
if (userId) {
loadUser(userId);
}
}, [userId]);
The rule is:
Call Hooks consistently at the top level. Put conditional logic inside the effect.
Multiple useEffect Hooks
A component can have multiple effects.
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
useEffect(() => {
localStorage.setItem("theme", theme);
}, [theme]);
This is often better than creating one giant effect.
Each effect should ideally represent one logical synchronization process.
For example:
Effect 1 → Browser title
Effect 2 → Local storage
Effect 3 → WebSocket
Effect 4 → API subscription
This makes the component easier to understand and maintain.
Multiple Effects With Different Dependencies
useEffect(() => {
console.log("User changed");
}, [userId]);
useEffect(() => {
console.log("Theme changed");
}, [theme]);
useEffect(() => {
console.log("Count changed");
}, [count]);
Now each effect responds to its own dependency.
userId changes → Effect 1
theme changes → Effect 2
count changes → Effect 3
Changing count doesn't cause Effect 1 or Effect 2 to execute merely because those effects exist.
Effect Dependency Array and Initial Execution
An effect with dependencies runs on the initial render as well.
For example:
useEffect(() => {
console.log(userId);
}, [userId]);
It doesn't mean:
"Run only when userId changes after the first render."
Instead, think:
Initial render
↓
Effect runs
Later render
↓
Compare userId
Changed?
↓
Yes → Effect runs
No → Effect doesn't run
Effect Cleanup Order
Suppose:
useEffect(() => {
console.log("Effect started");
return () => {
console.log("Cleanup");
};
}, [value]);
When value changes:
Previous effect
↓
Cleanup previous effect
↓
New effect
So conceptually:
value = 1
↓
Effect for 1
value = 2
↓
Cleanup for 1
↓
Effect for 2
value = 3
↓
Cleanup for 2
↓
Effect for 3
This is essential for subscriptions, timers, event listeners and connections.
useEffect With Timers
setTimeout
useEffect(() => {
const timer = setTimeout(() => {
console.log("Executed after 3 seconds");
}, 3000);
return () => {
clearTimeout(timer);
};
}, []);
The cleanup prevents the timeout from firing if the component is removed before the timeout completes.
setInterval
useEffect(() => {
const timer = setInterval(() => {
console.log("Executing every second");
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
The difference is:
setTimeout
→ Executes once
setInterval
→ Executes repeatedly
Both should generally be cleaned up when they are no longer needed.
Timer Depending on State
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 1000);
return () => {
clearInterval(timer);
};
}, [count]);
Whenever count changes:
Old interval cleared
↓
New interval created
↓
New interval sees current count
Whether recreating the interval is desirable depends on the use case.
For continuously running timers that need the latest state, there are sometimes better patterns involving functional state updates or refs.
Functional State Update With Effects
Suppose you want a counter that increases every second.
A straightforward approach is:
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => {
clearInterval(timer);
};
}, [count]);
This works, but the interval is recreated every time count changes.
A better pattern is:
useEffect(() => {
const timer = setInterval(() => {
setCount(previousCount => previousCount + 1);
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
Now the interval doesn't need count as a dependency because React provides the latest state through:
previousCount => previousCount + 1
This is an important connection between useEffect and functional state updates.
useEffect for DOM Interaction
Sometimes you need to interact with a DOM element.
For example:
const Input = () => {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} />;
};
The sequence is:
Component renders
↓
Input DOM element created
↓
Ref points to element
↓
Effect runs
↓
focus()
This is useful for integrating React with imperative browser APIs.
useEffect With Third-Party Libraries
Suppose a third-party chart library requires a DOM element.
Conceptually:
const Chart = () => {
const chartRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const chart = createChart(chartRef.current);
return () => {
chart.destroy();
};
}, []);
return <div ref={chartRef} />;
};
React manages the component lifecycle while the third-party library manages the chart.
The effect becomes the bridge:
React component
↓
useEffect
↓
Third-party library
↓
DOM / external system
Cleanup then disconnects the external system.
useEffect and Previous Values
useEffect itself does not directly provide the previous value.
You can combine it with useRef.
const [count, setCount] = useState(0);
const previousCount = useRef(count);
useEffect(() => {
console.log(
`Previous: ${previousCount.current}`
);
console.log(
`Current: ${count}`
);
previousCount.current = count;
}, [count]);
Conceptually:
count = 0
previousCount = 0
count changes to 1
↓
Effect
↓
previous = 0
current = 1
↓
Store 1 as previous
This is useful when an effect needs to compare current and previous values.
useEffect for Authentication Redirects
For example:
useEffect(() => {
if (!isAuthenticated) {
navigate("/login");
}
}, [isAuthenticated, navigate]);
The effect reacts to authentication state.
isAuthenticated = true
↓
No redirect
isAuthenticated = false
↓
Navigate to /login
This is another example of synchronizing application state with an external system such as routing.
useEffect for Search
Suppose you have a search box:
const [search, setSearch] = useState("");
const [results, setResults] = useState<User[]>([]);
You could fetch results whenever the search value changes:
useEffect(() => {
if (!search.trim()) {
setResults([]);
return;
}
fetch(`/api/users?search=${search}`)
.then(response => response.json())
.then(data => {
setResults(data);
});
}, [search]);
The lifecycle is:
User types "s"
↓
search changes
↓
Effect runs
↓
API request
User types "y"
↓
search changes
↓
Effect runs
↓
API request
For production search boxes, you would often add debouncing so that an API request isn't sent for every keystroke.
Debouncing With useEffect
useEffect(() => {
const timer = setTimeout(() => {
if (search.trim()) {
fetch(`/api/users?search=${search}`);
}
}, 500);
return () => {
clearTimeout(timer);
};
}, [search]);
Now:
User types
↓
Timer starts
User types again
↓
Previous timer cancelled
↓
New timer starts
User stops typing
↓
500ms passes
↓
API request
This is a very practical use of effect cleanup.
useEffect and Stale Closures
This is an advanced but important topic.
Consider:
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
The effect has an empty dependency array.
The callback may capture the value of count from the render in which the effect was created.
This is called a stale closure problem.
For state updates, a functional updater can often solve the issue:
useEffect(() => {
const timer = setInterval(() => {
setCount(previous => previous + 1);
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
The functional updater receives the current state rather than relying on a potentially stale captured value.
useEffect Does Not Make a Component "Async"
A component remains a normal React component.
For example:
const Users = () => {
useEffect(() => {
fetch("/api/users");
}, []);
return <h2>Users</h2>;
};
The component doesn't pause rendering until the API finishes.
Instead:
Render UI
↓
Effect starts
↓
API runs asynchronously
↓
Response arrives later
↓
State updated
↓
Render again
This distinction is important.
useEffect and State Update Cycle
Consider:
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
fetchUsers().then(data => {
setUsers(data);
});
}, []);
There are two separate operations:
First render
users = []
↓
Render loading/empty UI
↓
Effect runs
After API response
API returns data
↓
setUsers(data)
↓
State changes
↓
Component renders again
↓
users contains API data
The effect itself doesn't directly "render the data."
It changes state, and state causes React to render the new UI.
useEffect and Dependency Mistakes
Suppose:
const [userId, setUserId] = useState(10);
useEffect(() => {
fetch(`/api/users/${userId}`);
}, []);
The effect uses userId, but the dependency array is empty.
This means the effect won't react when userId changes.
Usually the dependency should reflect the value used by the effect:
useEffect(() => {
fetch(`/api/users/${userId}`);
}, [userId]);
The general principle is:
Dependencies should describe the reactive values that the effect reads and whose changes should cause synchronization again.
Modern React tooling can help identify missing dependencies.
useEffect and eslint
React projects commonly use the react-hooks/exhaustive-deps lint rule.
For example:
useEffect(() => {
console.log(userId);
}, []);
A linter may warn that userId is missing from the dependency array.
Instead:
useEffect(() => {
console.log(userId);
}, [userId]);
Don't treat dependency warnings as something to silence blindly.
They often indicate that the effect's synchronization logic needs reconsideration.
useEffect With No Cleanup
Not every effect needs cleanup.
For example:
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
There is nothing persistent to clean up.
So:
useEffect(() => {
// Setup external state
}, [count]);
may be enough.
Cleanup is required when the effect creates or registers something that needs to be removed.
Examples:
setInterval → clearInterval
setTimeout → clearTimeout
addEventListener → removeEventListener
WebSocket → close
subscribe → unsubscribe
Third-party chart → destroy
A Practical Classification of useEffect
Most real-world effects fall into a few categories.
| Category | Example |
|---|---|
| API/network | fetch() |
| Browser API | document.title |
| Events | window.addEventListener() |
| Timer | setInterval() |
| Subscription | subscribe() |
| WebSocket | new WebSocket() |
| Storage | localStorage |
| Third-party library | Chart/editor/map |
| Synchronization | React state → external system |
| Cleanup | Remove listeners/connections |
This makes it easier to recognize when useEffect is appropriate.
A Complete Real-World Example
Consider a user search page.
import {
useEffect,
useState
} from "react";
type User = {
id: number;
name: string;
};
const UserSearch = () => {
const [search, setSearch] = useState("");
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!search.trim()) {
setUsers([]);
return;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
try {
setLoading(true);
const response = await fetch(
`/api/users?search=${encodeURIComponent(search)}`,
{
signal: controller.signal
}
);
const data = await response.json();
setUsers(data);
}
catch (error: any) {
if (error.name !== "AbortError") {
console.error(error);
}
}
finally {
setLoading(false);
}
}, 500);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [search]);
return (
<div>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search users"
/>
{loading && <p>Loading...</p>}
{users.map(user => (
<div key={user.id}>
{user.name}
</div>
))}
</div>
);
};
This single example demonstrates several important useEffect concepts:
search changes
↓
Effect executes
↓
500ms debounce timer
↓
Previous timer cleaned up if search changes
↓
API request starts
↓
AbortController tracks request
↓
New search
↓
Old request cancelled
↓
New request starts
↓
API response
↓
setUsers()
↓
Component renders results
This is much closer to how useEffect appears in real applications.
Common useEffect Mistakes
Mistake 1 — Using it for calculations
Avoid:
useEffect(() => {
setTotal(price * quantity);
}, [price, quantity]);
Prefer:
const total = price * quantity;
Mistake 2 — Missing dependencies
Avoid:
useEffect(() => {
loadUser(userId);
}, []);
when the effect is supposed to respond to changing userId.
Prefer:
useEffect(() => {
loadUser(userId);
}, [userId]);
Mistake 3 — Forgetting cleanup
Avoid:
useEffect(() => {
window.addEventListener("resize", handleResize);
}, []);
Prefer:
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
Mistake 4 — Updating a dependency inside the effect unnecessarily
Avoid patterns like:
useEffect(() => {
setCount(count + 1);
}, [count]);
because they can create an infinite update cycle.
Mistake 5 — Making the effect callback async
Avoid:
useEffect(async () => {
await loadData();
}, []);
Prefer:
useEffect(() => {
const loadData = async () => {
await fetchData();
};
loadData();
}, []);
Mistake 6 — Calling Hooks conditionally
Avoid:
if (userId) {
useEffect(() => {
// ...
}, []);
}
Prefer:
useEffect(() => {
if (userId) {
// ...
}
}, [userId]);
Mistake 7 — Creating unnecessary derived state
Avoid:
const [activeUsers, setActiveUsers] = useState<User[]>([]);
useEffect(() => {
setActiveUsers(
users.filter(user => user.active)
);
}, [users]);
Prefer:
const activeUsers = users.filter(user => user.active);
If the calculation is expensive, consider useMemo rather than automatically reaching for useEffect.
useEffect vs useState
These Hooks solve completely different problems.
useState
Stores component data:
const [count, setCount] = useState(0);
Think:
useState
↓
"What data should React remember?"
useEffect
Synchronizes with external systems:
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
Think:
useEffect
↓
"What external operation should happen because
the component has rendered or some reactive value changed?"
useEffect vs useMemo
These are also very different.
useMemo
Caches a calculated value:
const activeUsers = useMemo(
() => users.filter(user => user.active),
[users]
);
useEffect
Performs a side effect:
useEffect(() => {
saveUsersToServer(users);
}, [users]);
Think:
useMemo
→ Calculate and remember a value
useEffect
→ Synchronize with an external system
useEffect vs useCallback
useCallback remembers a function reference:
const loadUser = useCallback(() => {
// ...
}, [userId]);
useEffect performs an effect:
useEffect(() => {
loadUser();
}, [loadUser]);
Think:
useCallback
→ Stabilize function reference
useEffect
→ Run synchronization logic
useEffect vs Event Handler
This distinction is very important.
Suppose clicking a button saves data:
const handleSave = () => {
saveUser();
};
You generally don't need:
useEffect(() => {
saveUser();
}, [isSaveClicked]);
An event handler is appropriate because the action is caused directly by the user's click.
Use an effect when something needs to synchronize because of rendering/state/props changes.
A useful distinction is:
User interaction
↓
Event handler
versus:
Render/state/prop change
↓
useEffect
The Mental Model You Should Remember
The simplest way to understand useEffect is:
useEffectis React's mechanism for synchronizing a component with something outside React.
For example:
React State
↓
useEffect
↓
Browser API
or:
React State
↓
useEffect
↓
API Server
or:
React Component
↓
useEffect
↓
WebSocket
or:
React Component
↓
useEffect
↓
Event Listener
The dependency array determines when React needs to synchronize again, and the cleanup function determines how to stop the previous synchronization.
The Three Most Important Forms
Keep these three forms firmly in mind:
Runs after every render
useEffect(() => {
// Runs after every render
});
Runs after initial mount
useEffect(() => {
// Runs after initial mount
}, []);
Runs after initial mount and when dependencies change
useEffect(() => {
// Runs initially and when count changes
}, [count]);
And when cleanup is required:
useEffect(() => {
// Start / synchronize something
return () => {
// Stop / clean up something
};
}, [dependency]);
useEffect Complete Lifecycle
A good overall mental model is:
React Component
│
▼
Render
│
▼
DOM is updated
│
▼
Effect executes
│
┌────────────┴────────────┐
│ │
External No external
operation operation
│
▼
API / Timer /
Event / DOM /
WebSocket /
Storage /
Subscription
│
▼
State may change
│
▼
Re-render
│
▼
Dependency comparison
│
├── No change → Effect doesn't rerun
│
└── Changed
│
▼
Cleanup previous effect
│
▼
Run new effect
Final Rule: When Should You Use useEffect?
Before writing useEffect, ask:
Does this code need to interact with
something outside React?
│
├── No
│ ↓
│ Probably don't use useEffect
│
└── Yes
↓
useEffect may be appropriate
↓
What does it depend on?
↓
Add those dependencies
↓
Does it create a subscription,
timer, listener, connection, etc.?
↓
Yes
↓
Return a cleanup function
The most important principle is not:
"
useEffectis where I put code that should run after rendering."
A better and more accurate mental model is:
"I use
useEffectwhen my React component needs to synchronize with an external system, and the dependency array tells React when that synchronization needs to be repeated."