Skip to content

When Should You Actually Use useEffect in React? A Clear Decision Framework

Problem

When I started learning React hooks, I used useEffect for everything. Data fetching? useEffect. Derived state? useEffect. Responding to button clicks? useEffect.

Then I heard experienced developers say things like:

Reddit comment #1
"You should actively avoid using useEffect unless absolutely necessary"

and

Reddit comment #2
"useEffect is used incorrectly so often that it does end up being code smell most of the time"

I was confused. The hook exists in React, so why should I avoid it? When is it actually appropriate to use?

What’s the Real Purpose of useEffect?

After reading the React docs and many discussions, I found the answer:

useEffect is for synchronizing your component with external systems.

That’s it. Not for data fetching. Not for state updates. Not for responding to user events.

External systems include:

  • Browser APIs (document, window)
  • Third-party libraries (charts, maps)
  • WebSocket connections
  • Timers and intervals
  • Subscriptions

When to Use useEffect (Valid Cases)

1. Subscriptions

When I need to subscribe to external data sources:

ChatRoom.js
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}

The key parts:

  • connection.connect() - syncs with external system
  • return () => connection.disconnect() - cleanup when component unmounts or roomId changes
  • [roomId] - re-sync when roomId changes

2. Timers and Intervals

When I need to set up recurring operations:

Timer.js
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return <div>{seconds} seconds</div>;
}

3. Integrating with Non-React Libraries

When I use libraries that manage their own state:

Chart.js
function Chart({ data }) {
const canvasRef = useRef(null);
useEffect(() => {
const chart = new ChartLibrary(canvasRef.current, data);
return () => chart.destroy();
}, [data]);
return <canvas ref={canvasRef} />;
}

4. Document Title Updates

When I need to sync with browser APIs:

ProfilePage.js
function ProfilePage({ user }) {
useEffect(() => {
document.title = `${user.name} | Profile`;
}, [user.name]);
return <div>{user.name}</div>;
}

When NOT to Use useEffect

I used to use useEffect for these cases. Here’s what I learned.

1. Data Fetching

I used to write:

UserList.js
function UserList({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setData);
}, [userId]);
return <div>{data?.name}</div>;
}

The problems:

  • No loading state
  • No error handling
  • Race conditions when userId changes quickly
  • No caching

Now I use TanStack Query:

UserList.js
function UserList({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
});
if (isLoading) return <Spinner />;
if (error) return <Error />;
return <div>{data.name}</div>;
}

TanStack Query handles:

  • Loading states
  • Error handling
  • Caching
  • Race conditions
  • Background refetching

2. Derived State

I used to compute values in useEffect:

PriceDisplay.js
function PriceDisplay({ items, taxRate }) {
const [total, setTotal] = useState(0);
useEffect(() => {
const subtotal = items.reduce((sum, item) => sum + item.price, 0);
setTotal(subtotal + subtotal * taxRate);
}, [items, taxRate]);
return <div>Total: ${total}</div>;
}

This creates extra renders. Now I compute during render:

PriceDisplay.js
function PriceDisplay({ items, taxRate }) {
const subtotal = items.reduce((sum, item) => sum + item.price, 0);
const total = subtotal + subtotal * taxRate;
return <div>Total: ${total}</div>;
}

No useEffect needed. The value recalculates automatically when props change.

3. Responding to Events

I used to set a flag in an event handler and watch it in useEffect:

SearchForm.js
function SearchForm() {
const [query, setQuery] = useState('');
const [shouldSearch, setShouldSearch] = useState(false);
const [results, setResults] = useState([]);
useEffect(() => {
if (shouldSearch) {
searchAPI(query).then(setResults);
setShouldSearch(false);
}
}, [shouldSearch, query]);
const handleSubmit = () => {
setShouldSearch(true);
};
return (
<form onSubmit={handleSubmit}>
<input value={query} onChange={e => setQuery(e.target.value)} />
<button type="submit">Search</button>
<Results data={results} />
</form>
);
}

This is unnecessary complexity. I now handle events directly in the handler:

SearchForm.js
function SearchForm() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
async function handleSubmit(e) {
e.preventDefault();
const data = await searchAPI(query);
setResults(data);
}
return (
<form onSubmit={handleSubmit}>
<input value={query} onChange={e => setQuery(e.target.value)} />
<button type="submit">Search</button>
<Results data={results} />
</form>
);
}

Common Mistakes

1. Objects as Dependencies

I had this bug:

Connection.js
function Connection({ url, id }) {
const options = { url, id };
useEffect(() => {
connect(options);
}, [options]); // Runs on every render!
}

The problem: options is a new object on every render, so the effect runs every time.

I fixed it with useMemo:

Connection.js
function Connection({ url, id }) {
const options = useMemo(() => ({ url, id }), [url, id]);
useEffect(() => {
connect(options);
}, [options]);
}

Or by moving the object inside:

Connection.js
function Connection({ url, id }) {
useEffect(() => {
const options = { url, id };
connect(options);
}, [url, id]);
}

2. Missing Cleanup for Data Fetching

I wrote this:

UserProfile.js
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser);
}, [userId]);
return <div>{user?.name}</div>;
}

The problem: If userId changes while fetching, the old response might overwrite the new one.

I added cleanup:

UserProfile.js
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let ignore = false;
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!ignore) setUser(data);
});
return () => { ignore = true; };
}, [userId]);
return <div>{user?.name}</div>;
}

But really, I should just use TanStack Query instead.

Decision Framework

Before using useEffect, I ask:

Decision flowchart
Is this synchronizing with an external system?
├─ Yes → useEffect (with cleanup)
└─ No →
├─ Fetching data? → useQuery/useSWR/framework
├─ Derived from state/props? → Compute during render
├─ Responding to user action? → Event handler
└─ None of the above? → Reconsider if you need it

Summary

In this post, I explained when to use useEffect in React. The key point is: useEffect is for synchronizing with external systems (subscriptions, timers, third-party libraries, browser APIs). For data fetching, use TanStack Query or SWR. For derived state, compute during render. For event responses, handle in event handlers.

The “code smell” reputation comes from misuse, not from the hook itself. Before reaching for useEffect, ask: “Am I synchronizing with something outside React?” If not, there’s likely a better approach.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments