Blog

Part 11 · useEffect in React: Running Code After React Updates the Page

An Effect is code React runs after it updates the page. Learn when Effects run, why they run twice in development, how the dependency array works, and when you don’t need one.

In Part 2 you saw React render: it calls your components to work out what the page should show. In Part 4, changing state made React render again. In Part 6, event handlers ran code when someone clicked.

Some code doesn’t belong to any click. It should run because a component is on the page. Making the page title match a score is one example. Starting a timer is another. React’s tool for this is called an Effect, and you make one with the useEffect hook. React’s docs write Effect with a capital E, to tell it apart from the everyday word “effect”.

This part covers what an Effect is and when it runs. It also explains why Effects run twice in development, which Part 2 promised. And it covers the many times you don’t need an Effect at all.

Try this first

Read this code. Don’t press Run yet.

import { useEffect } from 'react'

function headingOnPage() {
  const h1 = document.querySelector('h1')
  return h1 ? h1.textContent : 'nothing yet'
}

export default function App() {
  console.log('Render. Heading on the page:', headingOnPage())

  useEffect(() => {
    console.log('Effect. Heading on the page:', headingOnPage())
  })

  return <h1>Hello!</h1>
}
Render. Heading on the page: nothing yet
Render. Heading on the page: nothing yet
Effect. Heading on the page: Hello!
Effect. Heading on the page: Hello!

document.querySelector('h1') is the browser’s own way to find the first <h1> on the page. If there is none, it gives back null. So headingOnPage() tells us what the page holds at that moment.

App asks this question twice. Once while it renders, and once inside the function it gives to useEffect.

Make a guess. Will both lines say “Hello!”?

Press Run and look at the Console.

While App renders, the page has no heading yet: “nothing yet”. Inside the Effect, the heading is there: “Hello!”. So React ran the Effect’s code later, after the <h1> was on the page.

The render line appears twice because of Strict Mode, as in Part 2. The Effect line appears twice too. That has a different reason, which we’ll see soon.

Reading the page while rendering isn’t something to do in real code. We do it here only to look.

Rendering must be pure

Part 2 said a component should give the same result every time it runs with the same inputs. React’s docs say: “Rendering code must be pure.” A pure function only works out its answer. It doesn’t change anything that existed before it was called.

Anything else a function does is called a side effect. Changing a variable outside the function is a side effect. So is changing the page by hand, starting a timer, or talking to a server. Even console.log is a small side effect. It does no harm, so we use it in examples to watch what React does.

Here is a component with a side effect in its render:

let guest = 0

function Cup() {
  guest = guest + 1
  return <h2>Tea cup for guest #{guest}</h2>
}

export default function App() {
  return (
    <div>
      <Cup />
      <Cup />
      <Cup />
    </div>
  )
}

Run it. The cups say 2, 4 and 6, not 1, 2 and 3.

Each time Cup renders, it changes guest, a variable that lives outside it. Strict Mode renders each Cup twice, so guest goes up twice for each cup. We checked: with Strict Mode off, the cups said 1, 2 and 3. But the bug was there all along. The page depends on how many times React happened to render. Strict Mode just made the bug easy to see.

So where can side effects go? There are two places:

  1. Event handlers. They run because the user did something, like a click. Part 6 covered them.
  2. Effects. They run because the component is on the page, after React has updated it.

Render, commit, paint

When something changes, React updates the page in steps. Two of them have names you’ll see often:

  • Render. React calls your components to get their JSX. Nothing on the page changes yet.
  • Commit. React changes the page to match that JSX. It adds, removes or changes only the tags that are different.

After the commit, the browser draws the new page on the screen. React’s docs call this paint.

An Effect runs after the commit. React’s docs say Effects “run at the end of a commit after the screen updates”. That is why the Effect in “Try this first” could find the heading.

1. React renders: it calls App and gets JSX. 2. React commits: it changes the page to match. 3. Usually, the browser paints the screen next. 4. Then React runs your Effect. App() returns <h1>Hello!</h1> the page now has <h1>Hello!</h1> you can see Hello! on the screen useEffect(() => { … }) runs now

When an Effect runs the first time. Press play, or step through it.

Here are the same steps in words.

  1. React renders: it calls App, and App returns <h1>Hello!</h1>.
  2. React commits: it puts a real <h1> on the page.
  3. Usually, the browser paints the screen next, so you can see “Hello!”.
  4. Then React runs your Effect.

Step 3 says “usually”. React’s useEffect page explains when. If the Effect was not caused by something like a click, React will generally let the browser paint first. After a click, React may run the Effect before the paint. Either way, the Effect runs after the commit.

Your first Effect

Here are the parts of an Effect:

import { useState, useEffect } from 'react'

export default function App() {
  const [score, setScore] = useState(0)

  useEffect(() => {
    document.title = 'Score ' + score
    console.log('The title is now', document.title)
  })

  return <button onClick={() => setScore(score + 1)}>Score: {score}</button>
}
The title is now Score 0
The title is now Score 0
The title is now Score 1
The title is now Score 2
  • import { useState, useEffect } from 'react' brings in the hook.
  • useEffect(...) is called at the top level of the component, like useState. The rules of hooks from Part 4 apply.
  • The function you pass in is the Effect’s code. React’s docs call it the setup function.

document.title is the page’s title. In a normal app, the browser shows it on the page’s tab, the small label above the page.

Run it and click twice. In a real app, the tab’s title would change with each click. Here, your code runs in a small frame inside this page. So the tab of this page doesn’t change. The Console shows the frame’s own title instead.

Two lines appear before you click. That’s Strict Mode again, and the next sections explain it. After that, each click logs one line.

The page title is not part of your JSX. It’s outside the part of the page that React controls. An Effect is how a component makes something outside React match its props and state. Other examples are a timer, a chat server, or a map made by another library.

React 19 has another way to set the title. You can put a <title> tag in your JSX, and React moves it to the right place. We use document.title here because it’s a simple outside thing to match.

When does an Effect run?

useEffect can take a second argument: an array. It’s called the dependency array. A dependency is a value the Effect depends on, and the array lists them. It decides when the Effect runs again.

This example has three Effects, one of each kind:

import { useState, useEffect } from 'react'

export default function App() {
  const [a, setA] = useState(0)
  const [b, setB] = useState(0)

  useEffect(() => {
    console.log('no array: runs')
  })

  useEffect(() => {
    console.log('[]: runs')
  }, [])

  useEffect(() => {
    console.log('[a]: runs, a is', a)
  }, [a])

  return (
    <div>
      <button onClick={() => setA(a + 1)}>Change a</button>
      <button onClick={() => setB(b + 1)}>Change b</button>
      <p>a is {a}, b is {b}</p>
    </div>
  )
}
no array: runs
[]: runs
[a]: runs, a is 0
no array: runs
[]: runs
[a]: runs, a is 0
no array: runs
[a]: runs, a is 1
no array: runs

Run it. Then click “Change a”, and then “Change b”. The first six lines are from Run. The next two are from the “a” click. The last line is from the “b” click.

How you call it When the Effect runs
useEffect(fn), no array after every commit
useEffect(fn, []) each time the component mounts: when it is added to the page
useEffect(fn, [a, b]) when it mounts, and after any commit where a or b changed

Adding a component to the page is called mounting it. Part 7 introduced the word. So [] means “run when the component mounts”. If the component is removed and added again later, that is a new mount, and the Effect runs again.

On Run, every Effect ran twice. In production, each would run once. With Strict Mode off (as in production), we counted three lines on Run. The clicks gave the same lines with Strict Mode on and off.

Notice two more things in the log. The three Effects ran in the order they are written. And after a click, each Effect ran at most once, even with Strict Mode on. The double run happens only when the component mounts.

Why Effects run twice in development

Here is an Effect that gives back a function:

import { useEffect } from 'react'

export default function App() {
  console.log('render')

  useEffect(() => {
    console.log('setup')
    return () => {
      console.log('cleanup')
    }
  }, [])

  return <p>Look at the Console.</p>
}
render
render
setup
cleanup
setup

The function that the setup gives back is called the cleanup function. Its job is to stop what the setup started. React calls it when the component is removed from the page. It also calls it before the setup runs again. Part 12 is all about cleanup. Here we only need to know it exists.

Run it. The order is render, render, setup, cleanup, setup.

  1. React renders App twice. That is Strict Mode checking that the render is pure, as in Part 2.
  2. React commits, and runs the setup.
  3. Then, in development only, React acts as if the component was removed. It runs the cleanup.
  4. Then it acts as if the component came back. It runs the setup again.

With Strict Mode off, we saw only “render” and “setup”.

React’s useEffect page puts it this way. React “will run one extra development-only setup+cleanup cycle before the first real setup”. This happens for every Effect, with or without an array. We checked that too: an Effect with no array printed the same five lines.

Why does React do this? Its StrictMode page says: “Your components will re-run Effects an extra time to find bugs caused by missing Effect cleanup.”

Think of an Effect that starts a timer and has no cleanup. In development, the extra setup starts a second timer, so you see the bug at once. We tried it with a clock that adds 1 each second, using the updater form you’ll see below and no cleanup. It showed 6 after 3.5 seconds, not 3. In a real app, the same bug would show up later. A user might leave a screen and come back, and a second timer would start.

So the extra run is a test. If your cleanup really stops what the setup started, the user sees no difference. Setup, cleanup, setup looks the same as one setup. React’s docs say the right question isn’t how to run an Effect once. It’s how to fix the Effect so it still works after this test.

Don’t turn Strict Mode off to hide the double run. React’s docs also warn against using a ref to skip the second setup. A ref is a box that keeps a value between renders, and Part 14 covers it. Both ways hide the bug instead of fixing it.

When a dependency changes

After a dependency changes, React first runs the old Effect’s cleanup, with the old values. Then it runs the new setup, with the new values:

import { useState, useEffect } from 'react'

export default function App() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    console.log('setup for', count)
    return () => {
      console.log('cleanup for', count)
    }
  }, [count])

  return <button onClick={() => setCount(count + 1)}>Add one</button>
}
setup for 0
cleanup for 0
setup for 0
cleanup for 0
setup for 1
cleanup for 1
setup for 2

Run it and click twice. The first three lines are the mount, with the Strict Mode test. Then each click gives one cleanup and one setup. The cleanup sees the old count, because it belongs to the old render. Part 4 called this a snapshot.

1. You click. setCount(1) asks for a new render. 2. React renders App again. Now count is 1. 3. React commits the changes to the page. 4. count changed, so the old cleanup runs first. 5. Then the new setup runs, with the new count. setCount(0 + 1) App() with count = 1 the page now matches the new JSX cleanup for 0 setup for 1

When a dependency changes. Press play, or step through it.

  1. You click. setCount(0 + 1) asks React for a new render.
  2. React renders App again. In this render, count is 1.
  3. React commits the changes to the page.
  4. React sees that count changed from 0 to 1. So it runs the old cleanup first: “cleanup for 0”.
  5. Then it runs the new setup: “setup for 1”.

How React compares dependencies

After each render, React compares each value in the array with its value from the last render. It uses Object.is, which you met in Part 5. If every value is the same, React skips the Effect.

For numbers and text, Object.is compares the values. For objects, arrays and functions, it asks: “is this the very same object?” That causes a common surprise:

import { useState, useEffect } from 'react'

export default function App() {
  const [size, setSize] = useState(10)
  const [clicks, setClicks] = useState(0)
  const options = { size: size }

  useEffect(() => {
    console.log('Effect runs. Size is', options.size)
  }, [options])

  return (
    <div>
      <button onClick={() => setClicks(clicks + 1)}>Clicks: {clicks}</button>
      <button onClick={() => setSize(size + 1)}>Size: {size}</button>
    </div>
  )
}
Effect runs. Size is 10
Effect runs. Size is 10
Effect runs. Size is 10
Effect runs. Size is 10

Run it and click “Clicks” twice. The size never changed. But the Effect ran after each click.

The line const options = { size: size } runs on every render. So every render makes a brand new object. Object.is({ size: 10 }, { size: 10 }) is false, because they are two different objects. So React thinks the dependency changed every time. We saw the same with a function made inside the component: it was new on every render too.

The fix is to depend on the plain value, and make the object inside the Effect:

import { useState, useEffect } from 'react'

export default function App() {
  const [size, setSize] = useState(10)
  const [clicks, setClicks] = useState(0)

  useEffect(() => {
    const options = { size: size }
    console.log('Effect runs. Size is', options.size)
  }, [size])

  return (
    <div>
      <button onClick={() => setClicks(clicks + 1)}>Clicks: {clicks}</button>
      <button onClick={() => setSize(size + 1)}>Size: {size}</button>
    </div>
  )
}
Effect runs. Size is 10
Effect runs. Size is 10
Effect runs. Size is 11

Now clicking “Clicks” doesn’t run the Effect. Clicking “Size” does, once.

Include every value the Effect uses

You don’t choose the dependencies. The code inside the Effect decides them. The rule is: list every value from the component that the Effect uses.

React’s docs call these values reactive, which means they can change from one render to the next. The docs say: “Reactive values include props, state, and all the variables and functions declared directly inside your component body.”

There is one exception you’ll see often. A set function from useState, like setCount, is the same function on every render. So you can leave it out of the array. Values made outside the component, at the top of the file, don’t need to be listed either. A render can’t change them.

What happens if you leave one out

Here is a timer that should count seconds. A timer is code the browser runs later. setInterval(fn, 1000) asks the browser to call fn every 1000 milliseconds. 1000 milliseconds make one second. It gives back a number that names the timer.

import { useState, useEffect } from 'react'

export default function App() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1)
    }, 1000)
    return () => clearInterval(id)
  }, [])

  return <p>Seconds: {count}</p>
}

The line return () => clearInterval(id) is the cleanup. clearInterval(id) stops that timer. Without it, Strict Mode’s extra setup would leave two timers running. Part 12 explains this fully.

Run it and wait a few seconds. The page shows “Seconds: 1”, and then it stays at 1.

The Effect has [], so it ran only for the first render. In that render, count was 0. The timer’s function belongs to that render, so for it, count is always 0. Every second it says setCount(0 + 1). That’s “make it 1”, again and again.

We say the Effect sees a stale value: it is old, and nothing updates it. Part 9 used the same word. The array [] was wrong. The Effect uses count, but the array doesn’t list it.

There are two correct fixes.

List count. With [count], the Effect runs again each time count changes. Each run stops the old timer and starts a new one. It works: we saw “Seconds: 3” after 3.5 seconds. But it starts a new timer every second.

Stop needing count. Use the updater function from Part 4:

import { useState, useEffect } from 'react'

export default function App() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1)
    }, 1000)
    return () => clearInterval(id)
  }, [])

  return <p>Seconds: {count}</p>
}

c => c + 1 means “whatever the count is by then, add one”. The Effect no longer reads count, so [] is now the truth. We saw “Seconds: 3” after 3.5 seconds, with only one timer running at a time.

A tool that checks the array for you

A linter is a tool that reads your code and warns about likely mistakes. Part 10 met one. React’s team makes a set of rules for a linter called ESLint: eslint-plugin-react-hooks. Its exhaustive-deps rule checks dependency arrays. “Exhaustive” means “with nothing left out”.

The Part 10 project uses a different linter, Oxlint. It already checks dependency arrays, with no extra settings. We ran that project’s Oxlint 1.87.0, with its own settings, on the stale timer above. Here are the first and last lines of its warning:

  ! react-hooks(exhaustive-deps): React Hook useEffect has a missing dependency: 'count'
  help: Either include it or remove the dependency array.

The lines in between show your code, with count and [] marked. The playground on this page runs no linter at all.

We also ran ESLint with React’s own eslint-plugin-react-hooks 7.1.1 on the same code. It printed this warning:

React Hook useEffect has a missing dependency: 'count'. Either include it or remove the dependency array. You can also do a functional update 'setCount(c => ...)' if you only need 'count' in the 'setCount' call.

That warning names both fixes above. On the updater version, neither linter printed anything.

React’s docs suggest treating this warning like an error. Don’t hide the warning. Change the code inside the Effect instead.

You might not need an Effect

Effects are for making things outside React match your component, like the page title or a timer. React’s docs have a whole page called “You Might Not Need an Effect”. Two cases from it come up all the time.

A value you can work out while rendering

Here, fullName is kept in state, and an Effect keeps it up to date:

import { useState, useEffect } from 'react'

export default function App() {
  const [firstName, setFirstName] = useState('Ana')
  const [lastName, setLastName] = useState('')
  const [fullName, setFullName] = useState('')
  console.log('Render. fullName is "' + fullName + '"')

  useEffect(() => {
    setFullName(firstName + ' ' + lastName)
  }, [firstName, lastName])

  return (
    <div>
      <input aria-label="First name" value={firstName} onChange={e => setFirstName(e.target.value)} />
      <input aria-label="Last name" value={lastName} onChange={e => setLastName(e.target.value)} />
      <p>Hello, {fullName}!</p>
    </div>
  )
}
Render. fullName is ""
Render. fullName is ""
Render. fullName is "Ana "
Render. fullName is "Ana "
Render. fullName is "Ana "
Render. fullName is "Ana "
Render. fullName is "Ana L"
Render. fullName is "Ana L"

Run it, then type “L” in the second box. Each render logs twice because of Strict Mode, so count pairs.

The first pair is the first render, with fullName still empty. The second pair is the render that the Effect asked for. Then the key “L” causes two more renders. The first one still has the old fullName, "Ana ". The Effect then fixes it, which asks for one more render.

So every key press gives two renders, and the first one uses an old value. React’s docs say this does “an entire render pass with a stale value”. With Strict Mode off, we counted 2 renders per key.

Part 9 gave the fix. A value you can work out from props or state is a derived value. Don’t keep it in state. Work it out while rendering:

import { useState } from 'react'

export default function App() {
  const [firstName, setFirstName] = useState('Ana')
  const [lastName, setLastName] = useState('')
  const fullName = firstName + ' ' + lastName
  console.log('Render. fullName is "' + fullName + '"')

  return (
    <div>
      <input aria-label="First name" value={firstName} onChange={e => setFirstName(e.target.value)} />
      <input aria-label="Last name" value={lastName} onChange={e => setLastName(e.target.value)} />
      <p>Hello, {fullName}!</p>
    </div>
  )
}
Render. fullName is "Ana "
Render. fullName is "Ana "
Render. fullName is "Ana L"
Render. fullName is "Ana L"

Now there is one render for Run and one for the key, and fullName is never old. The code is shorter too. The linter agrees. On the Effect version, eslint-plugin-react-hooks 7.1.1 gave an error. Its message says: Calling setState synchronously within an effect can trigger cascading renders. In plain words: setting state right away inside an Effect makes one render cause another.

Code that should run because of a click

This shop prints a thank-you message after “Buy”. The message is in an Effect that watches bought:

import { useState, useEffect } from 'react'

function Shop({ bought, onBuy }: { bought: boolean; onBuy: () => void }) {
  useEffect(() => {
    if (bought) {
      console.log('Message: thank you for buying!')
    }
  }, [bought])

  return <button onClick={onBuy}>Buy</button>
}

export default function App() {
  const [bought, setBought] = useState(false)
  const [show, setShow] = useState(true)

  return (
    <div>
      <button onClick={() => setShow(!show)}>{show ? 'Hide the shop' : 'Show the shop'}</button>
      {show && <Shop bought={bought} onBuy={() => setBought(true)} />}
    </div>
  )
}
Message: thank you for buying!
Message: thank you for buying!
Message: thank you for buying!

Run it. Press “Buy”, then “Hide the shop”, then “Show the shop”.

“Buy” printed the message once. That’s right. But “Show the shop” printed it again, twice, and nobody bought anything. The Effect can’t tell why bought is true. It only knows the shop was added to the page while bought was true. With Strict Mode off, we saw one extra message instead of two. It’s a bug either way.

React’s docs give a test for this. Ask why the code needs to run. Their rule is: “Use Effects only for code that should run because the component was displayed to the user.”

Here, the message should appear because the user pressed “Buy”. So it belongs in the click handler:

import { useState } from 'react'

function Shop({ onBuy }: { onBuy: () => void }) {
  function handleBuy() {
    onBuy()
    console.log('Message: thank you for buying!')
  }

  return <button onClick={handleBuy}>Buy</button>
}

export default function App() {
  const [bought, setBought] = useState(false)
  const [show, setShow] = useState(true)

  return (
    <div>
      <button onClick={() => setShow(!show)}>{show ? 'Hide the shop' : 'Show the shop'}</button>
      {show && <Shop onBuy={() => setBought(true)} />}
      <p>{bought ? 'Bought.' : 'Not bought yet.'}</p>
    </div>
  )
}
Message: thank you for buying!

Now the message appears once, for the click, and never again.

An everyday example

Think of a shop with an automatic door. The door opens because someone is standing there. Nobody pressed anything. That’s an Effect: it runs because the component is on the page.

The cash box opens because a person paid. That’s an event handler: it runs because someone did something. If the cash box opened every time the door did, people would pay just for walking in.

The exact version

The door picture says an Effect runs “when something appears”. That’s only true for []. An Effect with dependencies also runs again when they change. An Effect with no array runs after every commit. The rule that always holds is React’s test. Use an Effect only for code that should run because the component is on the page.

useEffect and useLayoutEffect

React has a second Effect hook, useLayoutEffect. Its docs say it runs before the browser paints the screen again.

You need it only rarely. React’s docs give one example. A tooltip is a small box of help text that appears next to something. To place it well, you must measure it before the user sees it. With useEffect, the user might see it jump. The same docs warn that useLayoutEffect “can hurt performance”.

We checked the order in React 19.3, with Strict Mode off. First the render, then the layout Effect, then the normal Effect.

Common mistakes

Leaving a value out of the array

The Effect then sees stale values, like the timer that stuck at 1. List every value the Effect uses. If it then runs too often, change the code so it doesn’t need the value. An updater function often helps.

An object or function made during render, in the array

It’s a new object on every render, so the Effect runs after every commit. Depend on plain values like numbers and text. Make the object inside the Effect.

Setting state in an Effect with no array

import { useState, useEffect } from 'react'

function App() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    setCount(count + 1)
  })

  return <p>Count: {count}</p>
}

The Effect sets state. That causes a render. After every render, the Effect runs again and sets state again. This never ends. (There’s no Run button here. It would keep your browser busy.)

We ran it with React 19.3 and Strict Mode on, in our test setup outside a browser. React didn’t stop it. After 2 seconds, the count was over 1,000 and still going up. React had printed this error message more than 10 times:

Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render.

The warning names both causes: no array, or a dependency that’s new on every render. The fix depends on what you wanted. Often you didn’t need the Effect at all.

Using an Effect to work out a value

You saw this with fullName. It costs an extra render, and that render uses an old value. Work the value out while rendering instead.

Passing an async function to useEffect

An async function is a function that can use await to wait for a result. It looks useful for an Effect. This example pretends to ask a server for a name: fakeServer waits half a second, then gives back “Ana”.

import { useState, useEffect } from 'react'

function fakeServer(): Promise<string> {
  return new Promise(resolve => setTimeout(() => resolve('Ana'), 500))
}

export default function App() {
  const [name, setName] = useState('...')

  useEffect(async () => {
    const result = await fakeServer()
    setName(result)
  }, [])

  return <p>Name: {name}</p>
}

TypeScript stops you here. Its error says the function is not assignable to parameter of type 'EffectCallback'. In plain words, useEffect doesn’t accept this kind of function. The playground doesn’t check types, so press Run anyway.

The Console shows an error message twice. It starts like this:

useEffect must not return anything besides a function, which is used for clean-up.

Then React stops with the error destroy is not a function, and the page goes empty.

An async function always gives back a promise: an object that stands for a result that comes later. React expects the setup to give back a cleanup function or nothing. Strict Mode’s extra cleanup tries to call the promise like a function, and that fails. With Strict Mode off, the name did appear. But the same error came later, when the component was removed.

The fix is in the rest of React’s warning. Write the async function inside the Effect, and call it:

import { useState, useEffect } from 'react'

function fakeServer(): Promise<string> {
  return new Promise(resolve => setTimeout(() => resolve('Ana'), 500))
}

export default function App() {
  const [name, setName] = useState('...')

  useEffect(() => {
    async function load() {
      const result = await fakeServer()
      setName(result)
    }
    load()
  }, [])

  return <p>Name: {name}</p>
}

Now the setup gives back nothing, and the name appears after half a second. Getting real data from a server needs one more step, for answers that arrive late. Part 12 and Part 13 cover it.

Turning off Strict Mode to stop the double run

The double run is a test, and it only happens in development. Write a cleanup that stops what the setup started. Part 12 shows how.

Practice

Press Edit on any example above and try these. Strict Mode is on, so count carefully.

  1. In the three-Effects example, change the last Effect to log b and use [b]. Guess the Console lines for Run, then one click on “Change a”, then one click on “Change b”.
  2. In the “When a dependency changes” example, add a second button that sets count back to 0. Click “Add one” twice, then your new button. Which two lines does that last click add?
  3. Write a component with a “Count” button and this Effect: useEffect(() => { console.log('count is', count) }, []). Click the button twice. What does the Console show, and what would the linter say?
  4. Fix the tea cups. Make Cup pure by giving it a guest prop, so the page says guest #1, #2 and #3.
Answers
  1. On Run: no array: runs, []: runs, [b]: runs, b is 0, and then the same three lines again (Strict Mode). “Change a” adds only no array: runs. “Change b” adds no array: runs and [b]: runs, b is 1.
  2. cleanup for 2 and then setup for 0. The cleanup belongs to the render where count was 2.
  3. It shows count is 0 twice, both on Run, and nothing after the clicks. The button still counts up, but the Effect has [], so it never runs again. Oxlint in the Part 10 project warns React Hook useEffect has a missing dependency: 'count', with the help line Either include it or remove the dependency array. ESLint’s React plugin says the same in one line. With [count], each click logs the new count once.
  4. For example:
function Cup({ guest }: { guest: number }) {
  return <h2>Tea cup for guest #{guest}</h2>
}

export default function App() {
  return (
    <div>
      <Cup guest={1} />
      <Cup guest={2} />
      <Cup guest={3} />
    </div>
  )
}

Strict Mode still renders each cup twice. But now that changes nothing, because Cup only reads its prop.

Interview questions

Try to answer each one out loud before you open the answer.

What is an Effect, and what is it for?

An Effect is code that React runs after it has committed a render to the page. You make one with useEffect. It makes something outside React match a component’s props and state. Examples are the page title, a timer, or a server connection.

A strong answer adds the contrast with event handlers. A handler runs because the user did something. An Effect runs because the component is on the page. React’s docs call Effects an escape hatch: a way to step outside React. If no outside system is involved, you probably don’t need one.

What is the difference between no array, [] and [a, b]?

With no array, the Effect runs after every commit. With [], it runs each time the component mounts. With [a, b], it runs on mount and after any commit where a or b changed. Before running again, React runs the last run’s cleanup.

A strong answer adds that React compares each value with Object.is. It also says you don’t pick the array to control timing. The array must list every reactive value the Effect uses: props, state, and values made in the component.

Why does my Effect run twice when the component mounts?

Strict Mode, in development only. React runs setup, then cleanup, then setup again, to check that the cleanup really stops what the setup started. In our test the order was render, render, setup, cleanup, setup. Without Strict Mode, it was render, setup. In production, an Effect runs once on mount.

A strong answer says how to fix it. Don’t turn Strict Mode off, and don’t skip the second run with a ref. Write a cleanup that stops what the setup started. Then the user can’t tell one setup from setup, cleanup, setup.

Why does an object in the dependency array make the Effect run every time?

An object made during render is a new object on every render. React compares dependencies with Object.is, which checks if it’s the very same object. Two objects with the same fields are still two objects, so the dependency always looks changed. Functions made in the component behave the same way.

Fixes: depend on the plain values inside, like a number or text. Or make the object inside the Effect. Or move it outside the component if it never changes. A strong candidate mentions useMemo and useCallback as a last resort (Part 21).

What is a stale value in an Effect? How do you avoid it?

Each render has its own props and state, and its Effect sees that render’s values. Leave a value out of the array, and the Effect doesn’t run again when it changes. It keeps the old value. In our timer example, count was stuck at 0 inside the Effect. So setCount(count + 1) kept setting 1.

Fixes: list the value, or change the code so it doesn’t need it. For state updates, the updater setCount(c => c + 1) removes the need. A strong answer mentions the exhaustive-deps linter rule, which reports the missing value. It may also name useEffectEvent, added in React 19.2. It lets an Effect read the newest value without listing it. Use it for code that should not make the Effect run again.

When should you not use an Effect?

When there’s no outside system. Two common cases. First, a value you can work out from props or state: work it out while rendering. An Effect that sets state for it costs an extra render, and that render uses an old value. Second, code caused by a user action, like a click: put it in the event handler. An Effect can’t tell why a value changed, so it may run at the wrong time.

A strong answer gives React’s test: use an Effect only for code that should run because the component was shown. It may also mention starting state over with a key, from Part 8.

Why can’t the function you pass to useEffect be async?

An async function always returns a promise. React expects the setup to return a cleanup function or nothing. React 19.3 warns that “useEffect must not return anything besides a function”. When it later tries to call the promise as a cleanup, it throws destroy is not a function. In Strict Mode that happens right away, on mount.

The fix: define an async function inside the Effect and call it. A strong answer adds that getting data from a server also needs a cleanup. It must ignore answers that arrive too late.

What is the difference between useEffect and useLayoutEffect?

useLayoutEffect runs before the browser paints the screen again. useEffect usually runs after the paint, so the user sees the new screen first. Use useLayoutEffect only when you must measure or change the page before the user sees it. Placing a tooltip is the usual example. Otherwise prefer useEffect, because useLayoutEffect can hurt performance.

A strong answer adds three details. After a click, React may run a useEffect before the paint too. React’s docs say state updates set inside useLayoutEffect are also handled before the paint. And Strict Mode runs layout Effects twice on mount too. In our test, the order was render, render, layout Effect, Effect, layout Effect, Effect.

Sources

  • Synchronizing with Effects, react.dev: what Effects are, “Rendering code must be pure”, Effects “run at the end of a commit after the screen updates”, the three kinds of dependency array, Object.is, why Effects run twice in development, not using a ref to skip the second run, and each render’s Effect seeing its own values.
  • useEffect, react.dev: setup and cleanup, “Reactive values include props, state, and all the variables and functions declared directly inside your component body”, the extra “setup+cleanup cycle”, painting before or after the Effect, object dependencies, the updater fix for timers, and infinite loops.
  • You Might Not Need an Effect, react.dev: derived values, the cost of an extra render, event logic in handlers, and “Use Effects only for code that should run because the component was displayed to the user.”
  • Keeping Components Pure, react.dev: pure functions, side effects, and the tea cup example.
  • Render and Commit, react.dev: render, commit and paint.
  • StrictMode, react.dev: “Your components will re-run Effects an extra time to find bugs caused by missing Effect cleanup.”
  • Lifecycle of Reactive Effects and Removing Effect Dependencies, react.dev: reactive values, and treating the dependency warning like an error.
  • useLayoutEffect, react.dev: it runs before the browser paints, state updates inside it are handled before the paint, the tooltip example, and “can hurt performance”.
  • React 19.2, React blog: useEffectEvent.
  • eslint-plugin-react-hooks and exhaustive-deps, react.dev: what the linter checks.
  • <title>, react.dev: setting the page title from JSX.
  • Object.is() and Document.title, MDN.
  • The logs, counts, error text and the infinite-loop numbers above come from running React 19.3.0 for this post. The linter messages come from running Oxlint 1.87.0 in the Part 10 project, and ESLint 10.12.0 with eslint-plugin-react-hooks 7.1.1.
  • This part follows the useEffect Fundamentals kata in react-katas.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.