A chat component reconnects its WebSocket every time you type a letter into a search box that has nothing to do with chat. Another component re-renders in a loop until the tab freezes. A third logs its useEffect twice on mount, but only in development. None of these are React bugs. The first two are the dependency array doing exactly what you told it to, and the third is Strict Mode checking that your effect can clean up after itself.
When to use useEffect in a React component
useEffect synchronizes a component with something that lives outside React: a server connection, a browser API, a timer, the DOM directly, a third-party widget. React renders your JSX, commits it to the DOM, and then runs your effects. When the render wasn’t caused by an interaction like a click, React usually lets the browser paint the new screen first.
So an effect is the wrong place to compute a value for render. If fullName can be derived from firstName and lastName, write this during render:
| |
Not an effect that calls setFullName after the fact. The effect version renders once with the stale value, schedules a state update, then renders again. That extra pass buys nothing. Reach for useEffect when the component needs to do something because it was displayed, not to calculate something it could compute directly.
How the useEffect dependency array works
The second argument to useEffect comes in three forms, and each one tells React something different about when to re-run the function.
| |
For a populated array, React compares each entry to its value on the previous render with Object.is. That’s the same check as ===, except NaN equals itself and 0 and -0 are different. If every dependency is identical to last time, React skips the effect. If one differs, it runs again. There’s no partial match, and no way to tell React “re-run only when this particular dependency changed”. That logic has to live inside the effect.
It also means you can’t leave a value out of the array to stop the effect from re-running. The array should list every reactive value the function body reads: state, props, and anything derived from them. Drop a value from the array but keep reading it, and the effect runs with a stale copy. The react-hooks/exhaustive-deps rule from eslint-plugin-react-hooks exists to catch exactly that mismatch. Treat its warnings as build errors instead of silencing them with a disable comment.
What the useEffect cleanup function does
An effect can return a function. React treats that return value as cleanup for whatever the effect started:
| |
React calls the cleanup in two situations: right before the effect runs again because a dependency changed, and when the component unmounts. It always cleans up the previous effect before starting the next one. Connect, disconnect, connect, disconnect. Never two connections open at once.
Anything an effect starts that needs an explicit stop belongs behind a cleanup function: subscribe needs unsubscribe, addEventListener needs removeEventListener, setInterval needs clearInterval. The same pattern fixes a subtler data-fetching bug, where a stale response lands after a newer one:
| |
If personId changes before the first request resolves, the cleanup sets ignore to true for that request, so its .then() callback does nothing instead of overwriting newer state with older data.
Why useEffect runs twice in development
In development, under StrictMode, React runs setup, then cleanup, then setup again, immediately after the first mount. Production runs setup once. React does this on purpose: your component will get unmounted and remounted in real use (navigate away from a route and come back, for instance), and Strict Mode forces that to happen on your machine first, where a missing cleanup shows up as a visible bug.
The mistake is to patch the symptom:
| |
The guard stops the second connect() call, but there’s still no cleanup, so the connection stays open after the component unmounts. If remounting breaks an effect, the effect is missing its cleanup. Write the cleanup function instead of guarding against the second run.
Common useEffect dependency mistakes
An object or function literal recreated on every render. Two objects with identical contents are not the same object:
| |
Put an object like that in a dependency array and the effect sees a “new” value on every render, whether or not anything you care about changed:
| |
Build the object inside the effect instead, and depend on the primitive values it’s made from:
| |
Reading state only to compute the next state. An interval that reads count to increment it forces the effect to depend on count, which recreates the interval on every tick:
| |
A functional update removes the read, and the dependency goes with it:
| |
One effect doing two unrelated jobs. Say one effect fetches the cities for a country and the areas for a city. With both in the same function and the same dependency array, changing the city refetches the cities too. Split them: one effect per independent piece of synchronization, each with its own array.
When you don’t need useEffect
Two patterns look like they need an effect and don’t.
Resetting state when a prop changes. Clearing a form’s local state when userId changes is tempting to write as an effect watching userId. It works, but the component renders once with the old state before the reset lands. Pass the changing value as the component’s key instead, and React treats it as a fresh instance, no effect involved:
| |
Something that happens because of a click, not because of a render. An “added to cart” notification that watches product.isInCart in an effect fires again on every page reload where the item is already in the cart, because the effect can’t tell “just added” from “already there”. The notification belongs in the click handler that adds the item, where it runs only when the user clicks:
| |
If something should happen because the user did something, put it in the event handler for that something. useEffect is for what has to stay in sync for as long as the component is on screen, regardless of what the user does. Timing a fetch against the component lifecycle is that kind of problem (the request side is covered in handling Fetch API errors), and so is disconnecting an observer on unmount, as with the Intersection Observer API.
The next time an effect misbehaves, leave the array alone at first. List what the function body reads, decide whether each read needs to be reactive at all, and check whether the code works with no effect in it. Fix the body, and the correct array follows from it. If the ordering of async callbacks around a render is what confuses you, read how async/await and the event loop work, which covers the call stack and the microtask queue.