
Quick answer: useEffect runs code after React renders and updates the DOM, for anything outside React: fetching data, subscribing to events, timers and browser APIs. The dependency array controls when it re-runs: no array means every render, an empty array means once on mount, listed values means when those change. A cleanup function returned from the effect runs before the next run and on unmount.

Table of Contents

What useEffect actually does on every render
A React component function describes what the screen should look like. useEffect is how you reach outside that description: talking to the network, the browser, a WebSocket, a third-party library, or anything React does not manage. The rule that explains every confusing behaviour is simple. The component function runs first, React paints the result, and only then does the effect run. The effect never blocks rendering.
function Clock() {
const [now, setNow] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id); // runs before the next effect and on unmount
}, []);
return <p>{now.toLocaleTimeString()}</p>;
}Two consequences follow. First, you cannot read or change the DOM inside the component body and expect it to survive rendering, so anything DOM-shaped belongs in an effect or an event handler. Second, because the effect runs after paint, it is the wrong place for anything the user must see before interaction: that work belongs in a server component, a loader, or derived state. Learn what belongs where first in the useState guide, then treat useEffect as the companion for everything useState cannot express.
The dependency array: three forms and what each means
The second argument decides when your effect re-runs. There are exactly three useful shapes, and every bug in production effects is a misunderstanding of one of them.
| Form | When it runs | Typical use |
|---|---|---|
useEffect(fn) | After every single render | Rarely correct; usually a missing dependency |
useEffect(fn, []) | Once, after the first render, then never again | Subscriptions, one-time initialisation |
useEffect(fn, [a, b]) | After the first render and after a or b changes | Fetching, syncing, reacting to props |
React compares each dependency with Object.is, so a new object, array or function identity counts as a change even when its contents are identical. That single fact explains most “my effect fires constantly” reports. The exhaustive-deps lint rule from eslint-plugin-react-hooks enforces the list for you; turn it on and treat every warning as a design question rather than noise. If a value genuinely should not retrigger the effect, that is a signal you may be deriving it inside the component instead of outside it.
Fetching data without race conditions
The classic beginner effect fetches data when a prop changes. It works in demos and fails in production, because a fast response for the old prop can arrive after the slow response for the new one and overwrite it. The fix is not a library: it is AbortController plus the effect’s cleanup.
function UserCard({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
setUser(null); // show the loading state
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
setUser(await res.json());
}
load();
return () => controller.abort(); // cancels the stale request
}, [userId]);
return user ? <h2>{user.name}</h2> : <p>Loading…</p>;
}Aborting on cleanup means a superseded request can never write state, and it also stops the network traffic instead of leaving sockets open. Setting the data to null inside the effect matters just as much: without it the component keeps showing the previous user while the new one loads, which users read as “the app ignored my click”. The fetch promise itself is covered in depth in the JavaScript Fetch API guide, and the async behaviour underneath lives in Promises and async/await.

Subscriptions, timers and the cleanup function
Effects that register something must unregister it. The pattern is always the same shape: create in the effect body, return a function that reverses the creation. Timers, event listeners, IntersectionObserver, WebSocket connections, third-party widgets and chat SDKs all follow it without exception.
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);The cleanup runs at two moments: immediately before the effect runs again, and once when the component unmounts. In React 18 Strict Mode in development, mount, unmount and remount intentionally happen twice to expose missing cleanups, so an event listener that fires double or a timer that ticks twice locally is Strict Mode telling you the truth. Do not add guard flags to silence it; return the cleanup and the problem disappears in development and production alike.
The stale closure problem and functional updates
An effect captures the variables from the render it was created in. If your dependency array omits a value the effect reads, the effect keeps reading the old one forever: that is a stale closure, and it is why the lint rule insists on a complete dependency list.
// BROKEN: count is always 0 inside the interval
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []); // count missing
// FIXED: functional update reads the latest value
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []); // nothing external is read anymoreThe functional form passes the previous value into your updater, so the effect no longer reads an outside variable and the empty dependency array becomes honest. When you genuinely need a latest value inside a long-running effect without retriggering it, store it in a ref: a ref’s current property is mutable and reading it does not subscribe the effect to anything.
Common mistakes that cause double fetches or infinite loops
- Passing a new object or function every render. An inline options object or a helper defined inside the component changes identity each render, so an array containing it never stabilises. Move it out of the component or wrap it in useCallback.
- Setting state that feeds the same dependency.
useEffect(() => setItems([...items, x]), [items])loops forever because the state update changes its own trigger. - Forgetting the empty array. Without dependencies the effect runs after every render, which is rarely intended and often expensive.
- Doing derived work inside the effect. Formatting, filtering and computing from props belongs in the render body or a useMemo; effects are for external systems.
- Ignoring the cleanup. Every addEventListener, setInterval and subscribe needs its matching remove, clear or unsubscribe.
Fix the design rather than the symptom. If an effect exists only to copy one piece of state into another, delete it and compute the value during render. If it exists to reset state when a prop changes, use the key prop on the component instead, which recreates it cleanly without any effect at all.
React 19, the Compiler, and where useEffect still belongs
The React Compiler automatically memoises render logic, so many effects people wrote purely for performance, wrapping values in useMemo or useCallback only to stabilise a dependency, are no longer necessary on new code. What the compiler deliberately does not touch is correctness-shaped effects: subscriptions, network calls, timers and browser APIs stay your responsibility, with the same dependency rules and the same cleanup contract as before.
The practical takeaway for 2026 is a shorter decision list. Value derived from props or state: compute during render. State that can be derived: compute it, do not store it. Synchronising with a non-React system: useEffect, with a complete dependency array and a cleanup. Event reactions: handlers, not effects. That list covers almost every situation, and each item is a design choice you can make before writing a line of hook code. To learn React properly with a mentor, Ampersand Academy teaches web development one-to-one.

Why useEffect runs twice in development builds
The same reasoning explains why React logs the well-known “missing dependency” warning without failing the build. The compiler cannot know whether reading a fresh value each run is intended, so it surfaces the question instead of deciding it for you. Answering it deliberately, every time, is what keeps an effect list readable a year later: an empty array means genuinely once, a short list means precisely those inputs, and a value deliberately excluded deserves a comment saying why, so the next reader does not “fix” it back into the list and reintroduce the loop you removed.
A dependency array is therefore also a small piece of documentation. Teams that treat it that way, and answer every lint question deliberately rather than suppressing it, end up with effects whose intent survives refactors, because the array makes the coupling visible at a glance. Teams that fight the rule end up with stale effects that quietly read old values, which cost far more to debug than the few minutes of discipline the rule asks for in the first place.
In React 18 and later, development builds intentionally mount, unmount and remount every component once to expose exactly the bugs this article describes: a subscription that is never released, a timer that outlives its component, a request whose response lands after unmount and calls setState on nothing. Production builds run each effect once, so a double invocation locally is not a defect in your code or in React — it is a stress test you get for free. The correct response is never a guard flag that skips the second run; it is a cleanup function that makes the second run harmless.
The shape of that cleanup depends on what the effect created. A timer needs clearInterval, a listener needs its matching removeEventListener, a fetch needs an AbortController, and a third-party widget needs whatever its destroy method is called. Once every effect returns the inverse of what it did, the Strict Mode double-mount stops producing symptoms and starts doing its real job: proving that your effects are symmetric. Treat any warning that survives that discipline as a genuine leak in production waiting to happen.
useEffect, rendering state and the loading question
Every data-fetching effect eventually faces the same design question: what does the user see while the request is in flight? The three honest answers are a loading state, the previous data with a subtle indicator, or a skeleton that reserves the final layout. What is never honest is showing stale data as if it were current. Because the effect runs after paint, the first frame always renders before the request even starts, so the loading state must exist in the initial render rather than being added later by the effect.
Error handling belongs to the same discussion. A rejected fetch inside an effect produces an unhandled promise rejection and a stuck spinner unless you catch it and store the error alongside the data, which is why production hooks model state as a small object with data, loading and error fields rather than three unrelated variables. Rendering the error where the data was expected, with a retry affordance, completes the loop. The dependency array decides when all three states restart, and a URL that changes on every render will restart them constantly — a stable dependency is part of the fetch design, not an optimisation detail.
When useEffect is the wrong tool
The most common misuse is deriving state: copying one piece of state into another inside an effect so the copy can be rendered. That pattern produces two sources of truth that disagree whenever the first changes during rendering, and the fix is always the same — delete the effect and compute the derived value directly in the component body, or with useMemo if the calculation is expensive. The same logic applies to responding to prop changes by resetting state; the key prop on a child component achieves the reset without any subscription at all.
Event reactions are the second misuse: fetching inside an effect triggered by state that only an event handler changes. That indirection makes the data flow harder to follow for no benefit, because the handler can simply await the fetch itself and update state when it settles. What remains after both exclusions is the genuinely effect-shaped work: synchronising with the browser, with networks, with timers and with libraries that live outside React. Keeping that boundary sharp is what makes the hook list in a component readable at a glance.
Frequently asked questions
When should useEffect run only once?
Pass an empty dependency array: useEffect(fn, []). Use it for subscriptions and one-time setup, and always return a cleanup function so the registration is reversed on unmount.
Why does my useEffect run twice?
In React 18 Strict Mode development builds, effects mount, unmount and remount on purpose to surface missing cleanups. Production runs once. If it also happens in production, a dependency is changing identity every render.
Is useEffect the right place to fetch data?
For client-side fetching in a client component, yes, with AbortController in the cleanup. For initial page data, prefer a server component or router loader so users see content sooner.
What is the difference between useEffect and useLayoutEffect?
useLayoutEffect runs synchronously after DOM mutations and before the browser paints, for measuring and immediately correcting layout. useEffect runs after paint and is correct for everything else.
How do I stop an infinite loop in useEffect?
Find the state update that changes one of the effect’s own dependencies. Either remove that dependency by using a functional update, or delete the effect and derive the value during render instead.
Last updated on · Written by Dinesh Kumar R

