useEffect Is Not a Lifecycle Method (Stop Treating It Like One)
Photo: Unsplash
If you learned React with class components, you almost certainly read useEffect as "the hook version of componentDidMount and componentDidUpdate." It's an understandable translation — and it's the root cause of more effect bugs than any other single misconception. Stale closures, double-fetching, infinite loops, effects that "don't see" the latest state: trace them back and you'll usually find someone reasoning about lifecycle when they should be reasoning about synchronization.
The React docs make the reframe explicit: effects let you "synchronize a component with an external system." Not "run code after mount." Synchronize. That word change fixes most of the bugs.
The model: effects keep two things in sync
An Effect describes a relationship: given this state, the outside world should look like this. When the state changes, React tears down the old synchronization and sets up a new one. The dependency array isn't a "run when these change" trigger list — it's the set of values your synchronization depends on, so React knows when the relationship is stale.
Think of a chat room connection:
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect(); // cleanup tears down the old sync
}, [roomId]); // depends on roomId
// ...
}You are not saying "connect on mount." You're saying "this component should be connected to roomId." When roomId changes from "general" to "random", React runs the cleanup (disconnect from general) and then the effect again (connect to random). The cleanup function is the heart of the model — it's what makes the relationship maintainable over time, and it's the thing lifecycle thinking ignores.
Why the lifecycle mental model breaks
The componentDidMount framing produces three predictable failures.
The empty dependency array becomes a lie. People write [] to mean "run once, like mount." But [] actually means "this effect depends on nothing." If the effect reads roomId, that's false — and you get a stale closure that connects to the wrong room forever.
Cleanup gets forgotten. In lifecycle thinking, cleanup is a separate concern you handle in componentWillUnmount. In synchronization thinking, cleanup is half of every effect — it runs before each re-sync, not just at unmount.
Effects get used for things that aren't synchronization at all. This is the big one. A huge fraction of effects in real codebases exist to transform state or respond to events — work that doesn't belong in an effect.
A reliable smell test from the React docs: if an Effect is only adjusting state based on other state, you probably don't need an Effect. Effects are for synchronizing with systems outside React — the DOM, the network, a subscription, a timer. Computing derived data or handling a button click are not that.
You probably don't need that effect
Two patterns cover most of the effects that shouldn't exist.
Derived state. If you can calculate something from existing props or state during render, do that — don't mirror it into state and sync it with an effect.
// Don't: an effect to keep a derived value in sync
function Cart({ items }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price, 0));
}, [items]); // extra render, easy to desync
return <p>Total: {total}</p>;
}
// Do: just compute it during render
function Cart({ items }) {
const total = items.reduce((sum, i) => sum + i.price, 0);
return <p>Total: {total}</p>;
}The effect version renders twice (once with the stale total, once corrected) and can drift if you ever set items without the effect firing as expected. The render-time version is impossible to desync because there's nothing to keep in sync.
Responding to events. If logic should run because the user did something — clicked submit, toggled a switch — it belongs in the event handler, not an effect that watches state for the side effect of the change. Event handlers know why they ran; effects only know what changed.
When you genuinely need an effect
Effects earn their place when you're talking to something React doesn't control:
- Opening and closing a WebSocket or event-source connection.
- Subscribing to a browser API like
matchMediaor a resize observer. - Manually integrating a non-React widget (a map, a chart library) that has its own imperative lifecycle.
- Fetching data tied to a parameter — though a framework or data library is usually the better home for this.
For all of these, write the effect as a synchronization: set up, return a cleanup, list every reactive value you read as a dependency. Let the ESLint exhaustive-deps rule tell you the real dependencies instead of trimming the array to force "mount-only" behavior. If a dependency forces re-syncs you don't want, the fix is to remove the dependency from the effect's logic — not to lie to React about what the effect reads.
Takeaways
useEffectsynchronizes your component with an external system; it is not a lifecycle hook, and reading it ascomponentDidMountcauses most effect bugs.- The dependency array is the set of values the synchronization depends on, not a "run when these change" trigger —
[]means "depends on nothing," which is often a lie. - Cleanup is half of every effect: it runs before each re-sync, not only at unmount.
- If an effect only computes state from other state, delete it and calculate during render; if logic responds to a user action, put it in the event handler.
- Reserve effects for genuine external systems — connections, subscriptions, non-React widgets — and trust
exhaustive-depsinstead of trimming dependencies.

