React in Depth

Effects and the Lifecycle: useEffect Without the Confusion

What an effect is, dependencies, cleanup, why effects run twice in development, and when you do not need an effect.

Lesson 0215 min read#react#useEffect#effects#hooks

On this page

  1. Words used in this lesson
  2. What an effect is for
  3. The real timeline
  4. Why effects run twice in development
  5. Dependencies: what goes in the array
  6. Async work: the fetch race
  7. You might not need an effect
  8. Effect Events: reading the latest value without re-running
  9. Putting it together: a checklist for any effect
  10. Try it
  11. Interview questions
  12. Key takeaways
  13. What's next

In one line: an effect is a small piece of code that keeps something outside React (a server connection, a timer, the page title) in sync with your component's current props and state. It runs after the screen is updated, it can clean up after itself, and it re-runs only when the values it uses change. Most of the time, though, you do not need one.

In lesson 1 you saw that React calls your component to render, and then commits to the DOM. Rendering has to be pure, so it can not talk to the outside world. But apps must talk to the outside world: they open connections, start timers, listen to window events. An effect is the place for that. It is also the place where most React bugs live, because people use it for things it was never meant for.

This lesson shows the real order effects run in, what the dependency array really does, why effects run twice in development (with a real browser run), a fetch race bug reproduced and fixed, a stale closure bug reproduced and fixed, and the list of cases where you should delete the effect. The next lesson, Hooks in Depth, covers the other hooks.

What I ran / checked. Node 22.22.0, React 19.3.0, react-dom 19.3.0, jsdom 29.1.1, esbuild 0.28.2, all in the development build. Most outputs come from components mounted with createRoot in jsdom and wrapped in act(). The Strict Mode "effects run twice" proof was also run in a real headless Chromium 141 driven by Playwright, from an esbuild bundle (not a Vite dev server). The fetch race uses a fake fetch that I wrote with timeouts: 80 ms for a "slow" user and 10 ms for a "fast" one, so the order is exact and repeatable. I read these pages from the reactjs/react.dev repository (raw markdown): Synchronizing with Effects, You Might Not Need an Effect, Lifecycle of Reactive Effects, Separating Events from Effects, Removing Effect Dependencies, and the references for useEffect, useEffectEvent and Strict Mode. All code and explanations are mine. useEffectEvent is exported from react in 19.3.0 and has a normal reference page in the docs. I did not check in which release it became stable, so if you are on an older 19.x, look at your version's notes before you use it.

Words used in this lesson

  • Effect is code React runs after a render has been committed, to sync the component with something outside React.
  • Side effect is anything that reaches outside the render function: network, timers, subscriptions, DOM changes by hand.
  • Setup and cleanup are the two halves of an effect. Setup starts something. Cleanup (the function you return) stops it.
  • Dependency array is the list of values an effect uses. React re-runs the effect only when one of them changes.
  • Reactive value is any value that can change between renders: props, state, and anything computed from them in the component body.
  • Synchronize means keeping two things matching, here: the outside world matches the current props and state.
  • Stale closure is a function that remembers an old value of a variable, from the render in which it was created.
  • Race condition means the result depends on which of two async things finishes first.
  • Effect Event is a function made with useEffectEvent that always sees the latest values but does not count as a dependency.
  • Strict Mode runs one extra setup and cleanup cycle for every effect on mount, in development only.

What an effect is for

Think about a chat room component. When it appears, it should connect to the server. When the room changes, it should disconnect from the old room and connect to the new one. When it disappears, it should disconnect. That whole story is "keep the connection matching the current roomId".

The shape of an effect:

JavaScript
useEffect(() => {  // setup: start something  return () => {    // cleanup: stop it  };}, [dependencies]);

React calls the setup function after the component is committed to the screen. If you return a function, React calls it (the cleanup) before running the effect again and when the component is removed.

The docs make a point I like: think of an effect as describing how to start and stop synchronizing with something, for one particular set of values. Do not think "run this on mount" or "run this on update". Those are class component thoughts (componentDidMount). They lead you to wrong dependency lists.

The real timeline

I wrote a room component whose effect logs when it connects and disconnects:

JavaScript
function Room({ roomId }) {  useEffect(() => {    L(`connect ${roomId}`);    return () => L(`disconnect ${roomId}`);  }, [roomId]);  return <p>{roomId}</p>;}

The parent has two buttons: one changes roomId, the other changes an unrelated counter n. I clicked through four situations:

TEXT
1. mount (no StrictMode)  connect general2. unrelated state changes  (nothing)3. roomId changes  disconnect general  connect music4. unmount  disconnect music

Read it like this:

  1. On mount: setup runs.
  2. The unrelated counter changed. The Room component re-rendered (its parent did), but roomId is the same, so React skipped the effect. Nothing ran. This is what the dependency array is for.
  3. roomId changed. React ran the old cleanup first (disconnect general), then the new setup (connect music). Cleanup always sees the old values, because it closes over the render it came from.
  4. On unmount: the last cleanup runs.

Compare the three forms of the dependency argument:

FormWhen the effect runs
useEffect(fn, [a, b])After the first render, and again when a or b changed
useEffect(fn, [])After the first render only (and cleanup on unmount)
useEffect(fn) (no array)After every render

React compares each dependency to its previous value with Object.is, like state. It does not "decide" what to depend on. You do not choose the dependencies. They are determined by the code inside the effect. Every reactive value the effect reads must be in the array. The linter rule react-hooks/exhaustive-deps checks this, and you should leave it on.

Why effects run twice in development

If you add <StrictMode> (Vite and Next.js starters do), React runs an extra setup, cleanup, setup sequence on mount in development. It does this to check that your cleanup really undoes your setup. I ran the same Room in Strict Mode:

TEXT
5. mount in StrictMode  connect general  disconnect general  connect general6. StrictMode: roomId changes (no extra run)  disconnect general  connect music

Three lines on mount. React pretended the component was removed and shown again, to prove the cleanup works. On later changes there is no extra cycle. In production the extra cycle does not happen.

To see why this is useful, here are two versions that open a connection. One has no cleanup:

JavaScript
function Leaky() { useEffect(() => { open++; L("open -> " + open); }, []); return null; }function Fixed() {  useEffect(() => { open++; L("open -> " + open); return () => { open--; L("close -> " + open); }; }, []);  return null;}
TEXT
7. Leaky in StrictMode, connections left open: 2  open -> 1  open -> 28. Fixed in StrictMode, connections left open: 1  open -> 1  close -> 0  open -> 1

Leaky ends with 2 open connections, because nothing closed the first one. In production this would be a leak when a user navigates away and back. The double run exposed it on day one. Fixed ends with exactly one, which is right.

I also checked it in a real browser. The page had a component with a layout effect, a normal effect (with a requestAnimationFrame inside) and logs in the render:

TEXT
render w=0render w=0layout effect, DOM text = w=0effect (after paint)cleanup effectlayout effect, DOM text = w=0effect (after paint)next frame callback

In headless Chromium 141, with React in development mode and Strict Mode: the body rendered twice, then the layout effect saw the finished DOM text, the effect ran, was cleaned up, and ran again. Only one "next frame callback" appears because the first frame request was cancelled in the cleanup. The cleanup did its job.

The wrong fix for "my effect runs twice" is to add a useRef flag so it runs only once, or to remove Strict Mode. If the effect breaks when it runs twice, it would also break in the real app when a component is hidden and shown again. Write the cleanup. The React docs say this explicitly.

What should cleanup look like? A short list:

  • Subscription or event listener: remove it (removeEventListener, unsubscribe).
  • Timer: clearTimeout / clearInterval.
  • Connection: close it.
  • Fetch: ignore the old result or abort it (see the race section).
  • Something that has no meaningful cleanup, like sending an analytics "page viewed" event: in development you may see it twice. That is normal. The docs say to accept it, or to place the call where it runs once per navigation, which is a framework job.

Dependencies: what goes in the array

Object and function dependencies

Dependencies are compared with Object.is. An object or function created in the component body is a new one on every render, so an effect that depends on it re-runs every time:

JavaScript
function ObjDep({ n }) {  const options = { level: 1 };            // new object every render  useEffect(() => { runs++; }, [options]);  return <p>{n}</p>;}

I rendered it three times with identical props:

TEXT
effect with object dep ran 3 times for 3 renders (same props)effect with primitive dep ran 1 times for 3 renders

The version that depends on the primitive level ran once. The object version ran each time. The fix is to depend on the primitive values, or to move the object inside the effect, or outside the component if it is constant. The docs recommend this order: first try to move the object or function into the effect, since then it is not a dependency at all.

Setting state in an effect can loop

An effect that sets state to a new object that it also depends on is an infinite loop in waiting:

JavaScript
function Loop() {  const [x, setX] = useState([]);  useEffect(() => { setX([]); }, [x]);   // new array each time, x changes, effect re-runs ...  return null;}

I added a guard that stops after 30 renders so the test finishes:

TEXT
Loop component rendered 30 times with no user input (guard stopped it at 30)

Without the guard it keeps going (my first attempt without it hung my test runner until I stopped it, so do not try this in a real tab). In a real app React stops runaway updates in some cases, with a "maximum update depth" error, but not in all of them. The cause is always the same: the effect changes the thing it depends on.

Stale closures

A function remembers the variables from the render where it was created. If an effect has [] as dependencies but uses count, it remembers the first count forever. I wrote that bug:

JavaScript
function Stale() {  const [count, setCount] = useState(0);  useEffect(() => {    const id = setInterval(() => {      console.log("   stale interval sees count =", count);      setCount(count + 1);    }, 20);    return () => clearInterval(id);  }, []);     // lint would complain: count is missing  return <p>count {count}</p>;}

After about 110 ms (roughly five ticks):

TEXT
   stale interval sees count = 0   stale interval sees count = 0   stale interval sees count = 0   stale interval sees count = 0   stale interval sees count = 0Stale shows: count 1

Every tick saw count = 0 and set it to 1. The counter is stuck at 1. The fix that needs no re-subscribing is the updater form from lesson 1:

JavaScript
const id = setInterval(() => setCount(c => c + 1), 20);
TEXT
Updater version shows: count 5

Five ticks, count 5. The updater does not read count from the closure, so count is no longer a dependency. This is the cleanest fix when you only need the previous state. When you need the latest value of a prop inside a long-lived callback (without resetting the effect), the tool is useEffectEvent, shown below.

Do not "fix" the linter by adding // eslint-disable-next-line react-hooks/exhaustive-deps. It hides the exact bug above. The docs treat suppressing it as a smell: the lint warning means your effect uses a value that can change.

Async work: the fetch race

Fetching data in an effect is the most common use, and the most common bug. Imagine a profile page. The user clicks "slow user", then quickly clicks "fast user". The slow response arrives last. Which one does the page show?

I wrote a fake API (slow answers in 80 ms, fast in 10 ms) and two components.

JavaScript
function Buggy({ name }) {  const [data, setData] = useState("loading...");  useEffect(() => {    fakeFetch(name).then(setData);  }, [name]);  return <p>{name}: {data}</p>;}function Fixed({ name }) {  const [data, setData] = useState("loading...");  useEffect(() => {    let ignore = false;    fakeFetch(name).then(res => { if (!ignore) setData(res); });    return () => { ignore = true; };  }, [name]);  return <p>{name}: {data}</p>;}

I rendered each with name="slow", immediately re-rendered with name="fast", and printed the text after 30 ms and after 130 ms:

TEXT
Buggy after 30ms : fast: profile of fastBuggy after 130ms: fast: profile of slowFixed after 30ms : fast: profile of fastFixed after 130ms: fast: profile of fast

Look at the second line. The label says fast, but the data says profile of slow. The old request finished last and overwrote the right answer. The user would see the wrong profile with no error. The Fixed version marks the old effect as stale in its cleanup (ignore = true), so the late answer is dropped. You could also cancel the request with AbortController (the cleanup calls controller.abort()), which also saves bandwidth. See Promises and async/await for how promises resolve out of order.

For real apps, I recommend not writing this by hand. A data library like TanStack Query, or a framework's loaders, handles races, caching and loading states for you. Lesson 5 covers that. But you should be able to write the ignore pattern from memory, because it is a standard interview question.

You might not need an effect

This section may be the most valuable one in the lesson. The React docs have a long page on it, and I agree with its main point: effects are an escape hatch for talking to systems outside React. If no outside system is involved, you probably do not need an effect. Unneeded effects make code slower (extra renders), harder to follow, and buggy.

Here are the common cases and the replacement.

1. Computing something from props or state

Bad: store the result in state and update it in an effect. I tested both:

JavaScript
// with an effectconst [full, setFull] = useState("");useEffect(() => { setFull(first + " " + last); }, [first, last]);// without an effectconst full = first + " " + last;

I logged each render. Both versions started with first = "Ada", then I clicked a button that changes it to "Grace":

TEXT
effect version, mount  render: first=Ada full=""  render: first=Ada full="Ada Lovelace"effect version, click  render: first=Grace full="Ada Lovelace"  render: first=Grace full="Grace Lovelace"render version, mount  render: first=Ada full="Ada Lovelace"render version, click  render: first=Grace full="Grace Lovelace"

The effect version renders twice for every change, and the first of the two shows wrong data (first=Grace but full="Ada Lovelace"). The user might see that for one frame. The render version computes it directly and is right the first time. The rule: if you can calculate it during render, calculate it during render. If the calculation is slow, wrap it in useMemo (lesson 3), still without an effect.

2. Resetting state when a prop changes

Bad: an effect that calls setComment("") when userId changes. Good: <Profile key={userId} />. You saw it in lesson 1: a changed key makes a fresh component with fresh state, and there is no flash of old state.

3. Handling a user action

If something happens because the user clicked, put the code in the click handler, not in an effect that watches some state. An example: after a user submits a form, you want to show a notification. Do not do setSubmitted(true) and then useEffect(() => { if (submitted) showToast(); }, [submitted]). Call showToast() in the submit handler. An event handler knows exactly what the user did. An effect only knows that some value changed, and it may run for reasons you did not plan (including the Strict Mode double run).

Ask this question: why does this code run? If the answer is "because the user did X", it belongs in an event handler. If the answer is "because the component is on the screen" (it shows an ad, it connects to a server), it belongs in an effect.

4. Chains of effects

An effect sets state, which triggers another effect, which sets more state. Each link is an extra render. Do the whole calculation in the event handler, or derive values during render.

5. Sending data to a parent

An effect that calls onChange(value) to tell the parent after the state changed is fragile. Call the parent's function in the same handler that changes the state, or lift the state up to the parent.

6. Subscribing to an external store

If you need to read a browser value that changes (online status, window width) or a non-React store, use the dedicated hook useSyncExternalStore, which avoids tearing (parts of the screen showing different values). It is a lesson 3 topic.

7. Fetching data

Effects can fetch, as shown above, but handle races and consider a library. In React 19 with a framework, the better tool is often Suspense and use (lessons 3 and 5).

A summary table:

You want to...Use
Compute a value from props/stateA plain variable in render (or useMemo if slow)
Reset state when a prop changeskey
Run code because of a click or submitAn event handler
Share state with a parentLift state up
Subscribe to a browser or external storeuseSyncExternalStore
Connect to a server, timer, or DOM API that must match propsAn effect with cleanup
Fetch dataA data library, or an effect with an ignore flag

Effect Events: reading the latest value without re-running

Sometimes an effect needs to read a value but must not re-run when that value changes. The example from the docs: a chat room component connects to roomId, and shows a notification "Connected!" in the current theme. If theme were a dependency, changing the theme would disconnect and reconnect, which is wrong. You want the effect to depend on roomId only, and still use the latest theme when the notification is shown.

useEffectEvent makes a function that is part of the effect but always sees the latest props and state, and is not a dependency:

JavaScript
function Room({ roomId, theme }) {  const onConnected = useEffectEvent(() => L(`connected to ${roomId} (theme ${theme})`));  useEffect(() => {    L(`connect ${roomId}`);    onConnected();    return () => L(`disconnect ${roomId}`);  }, [roomId]);          // onConnected is not listed, and the linter agrees  return null;}

I rendered it with roomId="a" theme="light", then changed the theme, then changed the room:

TEXT
useEffectEvent mount  connect a  connected to a (theme light)theme changes  (nothing)roomId changes  disconnect a  connect b  connected to b (theme dark)

When the theme changed, the connection stayed (no output). When the room changed, the new notification used the newest theme, dark. That is the behaviour we wanted.

Rules for Effect Events, from the docs and my tests: call them only from inside effects (not from child components or other callbacks you pass around), do not add them to the dependency array, and use them for logic that is "an event that happens inside an effect", not to silence the linter for values that really should re-run the effect.

Putting it together: a checklist for any effect

When I write or review an effect, I ask:

  1. Is there an outside system? If not, delete the effect and use render or a handler.
  2. What does it start, and what stops it? Write the cleanup now, not later.
  3. What reactive values does it read? They all go in the array. Do not lie to the linter.
  4. Does it survive setup, cleanup, setup? Strict Mode checks this for free.
  5. If it is async, can an old result arrive late? Add ignore or an AbortController.
  6. Does it set state it also depends on? That is a loop.

Try it

  1. Write a component Clock that uses setInterval to update the time every second. Add cleanup. Put it in <StrictMode>. How many intervals should be running after mount? Check with a log in the setup and the cleanup.
  2. Write useEffect(() => { document.title = "Count " + count }, [count]). Click a different state around. When does the effect run?
  3. Take the Stale interval above, remove the cleanup, and unmount the component. What do you see in the console if the callback calls setCount?
  4. Reproduce the fetch race: make a fake fetch with different delays per input. Click both quickly. Then add the ignore flag.
  5. You have const [filtered, setFiltered] = useState([]); useEffect(() => setFiltered(items.filter(...)), [items]). Rewrite without an effect.
  6. A form shows a toast after save, using useEffect on a saved state. Move it to the event handler.
  7. Make an effect that depends on an object created in the component body. How often does it run? Change it to depend on a primitive.

Answers

  1. One interval after mount. In Strict Mode you see setup, cleanup, setup in the logs, so the first interval is cleared and only one remains.
  2. Only when count changes (and once on mount). Changes to other state do not re-run it.
  3. The timer keeps running after the component is gone, and keeps calling setCount on a component that no longer exists. That is the leak that cleanup prevents. In modern React you will not see the old "can't perform a state update on an unmounted component" warning (it was removed), but the timer keeps running and holding memory.
  4. Without the flag you see the data of the slow request on the fast label, as in my output. With the flag the data matches the label.
  5. const filtered = items.filter(...) (wrap in useMemo if it is slow). No state, no effect.
  6. Call showToast() inside the submit handler, after the save succeeds.
  7. Every render. Move the object inside the effect or depend on its primitive fields.

Interview questions

What is useEffect for, and what is it not for? (Must know)

It synchronizes a component with something outside React, such as a server connection, a timer, a browser API or a non-React widget. It runs after the render is committed. It is not for calculating values from props and state (do that in render), not for responding to a click (use the handler), and not for resetting state when a prop changes (use a key). I like to say that if there is no external system involved, I probably do not need an effect.

What does the dependency array do? (Must know)

It lists the reactive values the effect reads. After each render React compares each one with its previous value using Object.is. If any changed, it runs the cleanup from the last run and then the setup again. [] means no reactive values, so it runs once per mount. No array means it runs after every render. The array is not a choice I make to control timing. It is a fact about the code in the effect, and the exhaustive-deps lint rule helps me keep it true.

In what order do setup and cleanup run? (Must know)

On mount, setup. On an update where a dependency changed, the old cleanup runs first (with the old values), then the new setup. On unmount, the last cleanup. In my test, switching a room gave disconnect general and then connect music. Also, for effects in a tree, children's effects run before their parents'.

Why do my effects run twice in development? (Must know)

Strict Mode intentionally mounts, unmounts and mounts again each component on first mount, so effects go setup, cleanup, setup. It checks that cleanup properly undoes setup, so the component also survives future features like hiding and showing a screen while keeping state. It does not happen in production. I fix the missing cleanup rather than hiding the symptom. In my test a leaky effect ended with 2 open connections, and the fixed one with 1.

How do I fix a race condition in an effect that fetches data? (Must know)

Make the old effect ignore its result after it is stale. In the effect I set let ignore = false, check it before calling setData, and set it to true in the cleanup. Or I use an AbortController and abort in cleanup, which also cancels the network request. I reproduced the bug: the slow response for the old input arrived last and replaced the data for the new input, while the label showed the new input. Better still, I use a data library that deals with this.

What is a stale closure? Give an example. (Senior signal)

A function keeps the variables of the render it was created in. An effect with [] that reads count inside setInterval keeps seeing the first count. In my test, every tick saw 0 and set 1, so the counter froze at 1 instead of reaching 5. Fixes: the updater form setCount(c => c + 1) when I only need the previous state, listing the value as a dependency when re-running is acceptable, or useEffectEvent when I need the latest value without re-running.

Why is it a bad idea to compute derived state in an effect? (Must know)

It causes an extra render with inconsistent data. In my log, changing first first rendered with the new first and the old full, and then rendered again with the right full. The calculation could have happened during the single render. It also adds state that can drift out of sync. I compute in the render body, or in useMemo if it is expensive.

What is useEffectEvent? (Senior signal)

It creates a function that always sees the latest props and state, but is not reactive: it is not a dependency of the effect. I use it for logic that happens "as an event" inside an effect, like showing a notification when a connection opens, using the current theme. In my test, changing the theme did not reconnect, and changing the room reconnected and showed the notification with the newest theme. I only call it from inside effects, and I do not use it to just silence the linter when a value really should re-run the effect.

My effect has an object as a dependency and runs on every render. Why? (Trap)

Objects and functions created in the component body get a new identity on every render, and dependencies are compared by Object.is. In my test, three renders with the same props ran an object-dependent effect 3 times and a primitive-dependent one once. I depend on primitive values, move the object inside the effect, or define it outside the component if it never changes. I do not wrap everything in useMemo to hide this unless the object really is an input.

How do I reset a component's state when a prop changes, without an effect? (Senior signal)

I give it a key equal to the identity: <Chat key={contactId} />. When the key changes, React creates a new instance with fresh state. With an effect that calls setState(""), the component renders once with the old state first. I used this in lesson 1: with key={who} the typed text reset.

Is it OK to use async directly as the effect function? (Trap)

No. An async function returns a promise, and React expects the return value to be either nothing or a cleanup function. So I define an async function inside the effect and call it, or use .then. That also gives me a place to put the ignore flag.

When would you use useLayoutEffect instead of useEffect? (Senior signal)

When I must read layout from the DOM (like the size of an element) and change something before the browser paints, so the user never sees an intermediate frame. useLayoutEffect runs after the DOM is updated but before paint, and it blocks painting, so I use it sparingly. In my browser trace, the layout effect saw the final DOM text before the normal effect ran. For almost everything else, useEffect is right.

Key takeaways

  • An effect synchronizes your component with something outside React. If no outside system is involved, you probably do not need one.
  • Effects run after commit. When values change, the old cleanup runs first, then the new setup. The dependencies are determined by what the code reads, not by your wish.
  • In development Strict Mode runs setup, cleanup, setup on mount. Write cleanup so this is harmless.
  • Fetching in an effect needs a guard against races (ignore flag or AbortController), or a data library.
  • Stale closures freeze old values. Use updater functions, correct dependencies or useEffectEvent.
  • Derived values belong in render, user actions belong in handlers, state resets belong to key.

What's next

Next: Hooks in Depth, lesson 3. You have met useState and useEffect. The next lesson covers useRef, useMemo, useCallback and useReducer with one honest reason each, the rules of hooks (with the real error you get when you break them and a tiny hook engine I wrote to show why), use in React 19, and custom hooks. Practise now: write a useOnlineStatus function and note which parts you could extract from a component.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series