The rules of useEffect I wish I had earlier
useEffect is the most misunderstood hook in React. After shipping dozens of components that got it wrong, here is the mental model that fixed it.
useEffect is not a lifecycle method. I knew this. I read it in the docs. I said it to junior developers. And I still kept writing code that treated it like componentDidMount.
It took a genuinely bad bug — a stale closure that silently discarded user input — to make the mental model actually stick.
#The model that works
useEffect synchronizes your component with something outside React. Not "runs once on mount." Not "runs when X changes." Synchronizes with an external system.
That external system might be a WebSocket, a document title, a third-party library, or a browser API. The key word is external. If the thing you are synchronizing with is inside React — state, props, derived data — you probably do not need an effect at all.
function ChatRoom({ roomId }: { roomId: string }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}Every time roomId changes, the old connection disconnects and a new one connects. That is synchronization. That is what useEffect is for.
#The cleanup problem
Every effect that sets something up needs to tear it down. Not as a best practice — as a correctness requirement.
In Strict Mode (and React 19 concurrent features), your effect runs twice on mount to surface missing cleanups. If you see a double network request in dev, this is why.
// wrong — memory leak, duplicate listener
useEffect(() => {
window.addEventListener('resize', handleResize);
}, []);
// correct
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);Write the cleanup first, then write the setup.
#Fetching in effects is usually wrong
// the classic mistake
useEffect(() => {
fetch(`/api/user/${id}`)
.then(r => r.json())
.then(setUser);
}, [id]);This has two problems. First, no cancellation — if id changes before the fetch completes, the stale response overwrites the current state. Second, no loading or error state means no way to show the user what is happening.
If you must fetch in an effect, use an ignore flag:
useEffect(() => {
let ignore = false;
fetch(`/api/user/${id}`)
.then(r => r.json())
.then(data => { if (!ignore) setUser(data); });
return () => { ignore = true; };
}, [id]);Or better: reach for React Query, SWR, or the framework's data-loading primitive.
#The dependency array is a contract
The ESLint rule exhaustive-deps exists for a reason. When you write // eslint-disable-next-line react-hooks/exhaustive-deps, you are lying to React about what the effect depends on.
If adding a dependency causes an infinite loop, the problem is not the dependency — it is that you are mutating state inside an effect that depends on that state. Suppress the lint rule and you suppress the symptom, not the bug.
Every dependency you omit from the array is a stale closure waiting to surface at the wrong moment.
#When you do not need an effect
- Derived data: compute it during render, not in an effect
- User event side effects: handle them in the event handler
- State reset on prop change: use the
keyprop to remount instead - Chained state updates: merge into a single reducer or updater
Most of the effects I deleted in the past year were covering for missing derived values or misplaced event handlers. Deletion made the components simpler every time.
useEffect is not complicated. It is easy to use for the wrong thing, which is different. Synchronize with external systems. Write the cleanup. Prefer alternatives when they exist.