useEffect is one of React’s most useful hooks, and also one of the most misused. The basic rule of thumb is to use effects only to synchronize with something outside React. If the logic can run during render, in an event handler, or via memoization, that is usually the better option.
The lifecycle behavior of useEffect
An effect runs after React commits a render to the DOM.
useEffect(() => {
// setup
return () => {
// cleanup
};
}, [deps]);
The lifecycle is:
- Initial mount: run setup after the first render is committed.
- Dependency change: run cleanup for the previous effect, then run setup again.
- Unmount: run cleanup one final time.
Dependency array behavior:
- No array: runs after every render.
- Empty array
[]: runs once after mount, cleanup on unmount. - With deps
[a, b]: runs after mount and wheneveraorbchanges.
In React Strict Mode (development), React may intentionally run setup and cleanup an extra time to surface side-effect bugs. This is expected in dev and does not happen the same way in production.
How the return value works in useEffect
useEffect expects either:
- nothing (
undefined), or - a cleanup function.
Example:
useEffect(() => {
const id = setInterval(tick, 1000);
return () => {
clearInterval(id);
};
}, []);
Important detail: a function reference is returned (the cleanup function). React calls that function later:
- before running the effect again when dependencies change, and
- when the component unmounts.
Think of it as “setup now, teardown later.”
Why useEffect should be used sparingly
Effects are imperative. React is primarily declarative. The more effect-heavy a component becomes, the harder it is to reason about ordering, dependencies, and re-renders.
Imperative code tells React step by step what to do and when to do it. In contrast, declarative React code describes what UI should look like for a given state. Effects are necessary for external synchronization, but overusing them pulls component logic away from the simpler state-to-UI model.
Common issues in effect-heavy components:
- Stale values captured by closures. For example, an interval may keep reading the
countvalue from the render that created it, instead of the latest value shown on screen. - Missing dependencies that cause subtle bugs. For example, a fetch effect that uses
userIdbut omits it from the dependency array may keep loading data for the previous user after props change. - Extra renders due to effect-driven state updates. For example, deriving
fullNamefromfirstNameandlastNamein an effect causes React to render once with stale derived state, then again after the effect updates it. - Race conditions when async work completes out of order. For example, a slower request for an older search term can finish after a newer request and replace the current results with stale data.
- Cleanup bugs that leak listeners, timers, or requests. For example, adding a
resizelistener or starting an interval without cleanup means that work can continue after the component unmounts.
In short, effects are not “run code after render” by default. They are for bridging React with external systems.
Problematic patterns and better alternatives
The examples below are not exhaustive, but they cover several common situations where useEffect is often used out of habit. In these cases, the same logic can usually be expressed more directly during render, in an event handler, or through a clearer state model.
1. Deriving state with an effect
This is a common anti-pattern:
function Product({ price, taxRate }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(price * (1 + taxRate));
}, [price, taxRate]);
return <p>Total: {total}</p>;
}
This causes an extra render because React first renders with the initial total value, commits that render, then runs the effect. The effect calls setTotal, which schedules a second render with the calculated value.
It duplicates state because the calculation does not use the existing total state as an input. The effect does not do setTotal(total + something). It does setTotal(price * (1 + taxRate)), which means total is fully recalculated from price and taxRate every time.
So the source values are price and taxRate, and total is only a stored result of those values. If price is 10 and taxRate is 0.2, React already has enough information to calculate 12 during render. Putting 12 into state stores a second copy of information that can be recalculated from the first copy.
Prefer direct computation:
function Product({ price, taxRate }) {
const total = price * (1 + taxRate);
return <p>Total: {total}</p>;
}
If the calculation is expensive, use useMemo, not useEffect.
2. Reacting to user events with an effect
useEffect(() => {
if (submitted) {
saveForm(data);
}
}, [submitted, data]);
This can be brittle and indirect.
The user action already has a natural place to run this code: the submit event handler. Using an effect adds an extra state flag, submitted, whose only job is to trigger the effect later. That makes the flow harder to follow because the action starts in one place, updates state, causes a render, and only then runs the code that actually saves the form.
It can also run more often than intended. If data changes while submitted is still true, the effect runs again because data is in the dependency array. That can submit the form more than once unless extra guard logic is added.
Prefer doing it in the event handler:
const onSubmit = async (e) => {
e.preventDefault();
await saveForm(data);
};
3. Syncing props into local state
useEffect(() => {
setName(user.name);
}, [user]);
This can fight user edits and create timing bugs.
The prop and the local state are now two separate sources of truth for the same value. If the user edits name locally and user changes at the same time, the effect may overwrite the local edit with user.name. Even when that is intentional, the timing can be surprising because the component first renders with the current local state, then the effect runs and changes it after the render has committed.
Prefer one source of truth. Keep controlled state local, or derive directly from props when possible.
4. Data fetching without cancellation
useEffect(() => {
fetch(`/api/user/${id}`)
.then((r) => r.json())
.then((data) => {
setUser(data);
});
}, [id]);
Without cleanup, rapid id changes can show stale responses.
For example, if id changes from 1 to 2, both requests may be in flight at the same time. The request for 2 might finish first and show the correct user, then the slower request for 1 might finish later and overwrite the screen with stale data.
Add cleanup so the old effect cannot update state after React has moved on to a newer id:
useEffect(() => {
let cancelled = false;
fetch(`/api/user/${id}`)
.then((r) => r.json())
.then((data) => {
if (!cancelled) setUser(data);
});
return () => {
cancelled = true;
};
}, [id]);
This ignores stale responses. If the request itself should be cancelled, use an AbortController and pass its signal to fetch.
5. Returning a Promise from an effect
Do not return a Promise directly from an effect:
An async function always returns a Promise. React does not treat that Promise as cleanup, and it will not wait for it before unmounting or rerunning the effect. React expects the effect callback to return either nothing or a cleanup function, so the async work should live inside the effect instead.
// Wrong
useEffect(async () => {
const data = await fetchData();
setData(data);
}, []);
Use an inner async function instead:
useEffect(() => {
let active = true;
async function load() {
const data = await fetchData();
if (active) setData(data);
}
load();
return () => {
active = false;
};
}, []);
Closing notes
useEffect is not bad. It is just easy to overuse. When it is kept for external synchronization, components stay simpler, more predictable, and easier to maintain.
Before writing an effect, ask:
- Can I compute this during render?
- Can I run this in an event handler instead?
- Is this truly syncing with an external system?
- Did I include all dependencies?
- Do I need cleanup for listeners, timers, or requests?
If the answer to the first two questions is yes, skip useEffect.