Blog

Part 12 · useEffect Cleanup: Stopping Timers, Listeners and Requests

An Effect can return a cleanup function. Learn when React calls it, why Strict Mode runs it early, and how to stop timers, listeners, subscriptions and old requests.

In Part 11 you met useEffect. An Effect is code that React runs after it has changed the page. You saw the three forms of the dependency array. You also saw that in development, Strict Mode runs setup, cleanup and setup again when a component is added.

Many Effects start something that keeps going. A timer keeps running. A key listener keeps listening. A request is still on its way. Something must stop these when they are no longer needed. That is the job of the cleanup function.

This part shows exactly when React calls it, and why Strict Mode calls it early. Then we clean up five kinds of work: timers, event listeners, subscriptions, requests, and page changes that React doesn’t manage.

Try this first

This example pretends to connect to a chat room. The pretend connection only prints what it does. Read the code, but don’t press Run yet.

import { useEffect, useState } from 'react'

// A pretend chat server. It only prints what it does.
function createConnection(roomId: string) {
  return {
    connect() {
      console.log('connect to ' + roomId)
    },
    disconnect() {
      console.log('disconnect from ' + roomId)
    },
  }
}

function ChatRoom({ roomId }: { roomId: string }) {
  useEffect(() => {
    const connection = createConnection(roomId)
    connection.connect()
    return () => connection.disconnect()
  }, [roomId])

  return <p>You are in the {roomId} room.</p>
}

export default function App() {
  const [roomId, setRoomId] = useState('music')
  const [show, setShow] = useState(true)

  return (
    <div>
      <button onClick={() => setRoomId('games')}>Go to games</button>
      <button onClick={() => setShow(false)}>Leave</button>
      {show && <ChatRoom roomId={roomId} />}
    </div>
  )
}
connect to music
disconnect from music
connect to music
disconnect from music
connect to games
disconnect from games

The Effect returns a small function: () => connection.disconnect(). React keeps that function and calls it later.

Make two guesses. When you press “Go to games”, which line comes first: disconnect from music or connect to games? And what prints when you press “Leave”?

Now press Run. Look at the Console, then press “Go to games”, then “Leave”.

The first three lines appear as soon as the page loads. That is Strict Mode, and we’ll come back to it soon. Then “Go to games” prints disconnect from music first, and connect to games second. “Leave” prints disconnect from games. At the end, no connection is left open.

What a cleanup function is

The function you pass to useEffect is called the setup function. That is the name React’s docs use. The setup function may return another function. That second function is the cleanup function.

import { useEffect } from 'react'

function Example() {
  useEffect(() => {
    // setup: start something
    console.log('start')
    return () => {
      // cleanup: stop it
      console.log('stop')
    }
  }, [])
  return null
}

You never call the cleanup yourself. You only return it. React decides when to call it.

The cleanup should stop or undo what the setup started. If the setup opens a connection, the cleanup closes it. If the setup starts a timer, the cleanup stops that timer. React’s docs say it like this: “The cleanup function should stop or undo whatever the setup function was doing.”

Not every Effect needs a cleanup. If an Effect only reads something, or sets something to the same value again, there is nothing to stop. You can return nothing.

When React calls the cleanup

We ran the ChatRoom Effect in React 19.3 and logged every call. This table is with Strict Mode off, as in a production build:

What happens What React calls
The component is added to the page setup(music)
It renders again, roomId is still music nothing
roomId changes from music to games cleanup(music), then setup(games)
The component is removed from the page cleanup(games)

Here are the same rules in words.

  1. When the component is added to the page, React runs the setup.
  2. If the component renders again and no dependency changed, React does nothing with the Effect.
  3. When a dependency changed, React first runs the old cleanup. Then it runs the new setup.
  4. When the component is removed from the page, React runs the cleanup one last time.

Adding a component to the page is called mounting it. Removing it is called unmounting it. You’ll see both words in React’s docs and in error messages.

1. The component is added. React runs setup: connect to music. 2. Development only: Strict Mode runs cleanup at once, as a test. 3. Then setup runs again. One connection is open, as before. 4. roomId changes. First, cleanup runs with the old value, music. 5. Then setup runs with the new value, games. 6. The component is removed. React runs cleanup one last time. added to the page roomId: music to games removed from the page setup(music) cleanup(music)test setup(music)test cleanup(music) setup(games) cleanup(games) setup cleanup connections open: 1

When React calls setup and cleanup. Choose a mode, then press play or step through it.

Here are the steps from the figure, with Strict Mode on.

  1. The component is added. React runs setup: connect to music.
  2. Development only: Strict Mode runs the cleanup at once, as a test. disconnect from music.
  3. Then setup runs again. One connection is open, as before.
  4. roomId changes. First, the cleanup runs with the old value: disconnect from music.
  5. Then the setup runs with the new value: connect to games.
  6. The component is removed. React runs the cleanup one last time: disconnect from games.

Switch the figure to “Without Strict Mode” to see the production order. Steps 2 and 3 are gone. Everything else is the same.

Why the cleanup says “music”, not “games”

When you press “Go to games”, the new roomId is games. So why does the cleanup print disconnect from music?

The setup was made during the render where roomId was music. The cleanup is made inside it, so it sees music too. It remembers the values from that render. In Part 4 you saw that each render has its own snapshot of state. Each Effect, and each cleanup, belongs to one render too.

That is exactly what we want. The music connection must be closed, not the games one.

An everyday example

Think of a hotel room key. When you arrive, you get a key for room 12. When you move to room 30, you first give back the key for room 12. Then you get a key for room 30. When you leave the hotel, you give back the key for room 30.

Setup is getting a key. Cleanup is giving it back. You always give back the key you have, not the key you will get next.

The exact version

In the hotel, you give back the old key before anything else happens. React is a little different. When roomId changes, React first changes the page to show the games room. Only then does it run the old cleanup and the new setup.

We checked this with the ChatRoom above, with Strict Mode on and off. In the cleanup for music, we read the text on the page. It already said “You are in the games room”. React’s docs say the same: the cleanup and the new setup run “After every commit with changed dependencies”. A commit is when React puts the result of a render on the page, as Part 11 explained.

So the cleanup should only stop its own work. It shouldn’t read the page and expect the old value there.

Strict Mode runs the cleanup early, as a test

You met this test in Part 11. In development, Strict Mode adds one extra cleanup and setup when a component is added. Our chat room showed this order on mount:

connect to music
disconnect from music
connect to music

React’s docs call this a “stress-test”. It checks that your cleanup really stops what your setup started. If it does, nobody can tell the difference. The page ends up with one open connection, the same as in production.

If the cleanup is missing or wrong, the extra run shows it. You’ll see two timers, two listeners or two connections. That is the bug Strict Mode is trying to show you. Without Strict Mode, it would appear later, when a user leaves a page and comes back.

The extra pair happens only when the component is added. We checked: when roomId changed, Strict Mode added no extra calls. React ran just cleanup(music), then setup(games).

As Part 11 said, don’t turn Strict Mode off, and don’t use a ref to skip the second setup. Both hide the bug. The fix is to write the cleanup.

Cleaning up a timer

setInterval runs a function again and again. setInterval(fn, 1000) runs fn every 1000 milliseconds, which is every second. It returns a number, called the interval ID. clearInterval(id) stops that interval.

Part 11 told you about this bug. Here is the code, and then its fix. First, the clock with no cleanup. setSeconds(s => s + 1) is the updater form from Part 4. It adds one to the newest value.

import { useEffect, useState } from 'react'

export default function App() {
  const [seconds, setSeconds] = useState(0)

  useEffect(() => {
    setInterval(() => {
      setSeconds(s => s + 1)
    }, 1000)
  }, [])

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

Guess first: after three and a half seconds, what number will it show?

Press Run and watch. The number goes up by 2 every second. After three and a half seconds, it shows 6, not 3.

Strict Mode ran the setup twice. Each setup started an interval. Strict Mode tried to clean up in between. But there was no cleanup function, so nothing stopped the first interval. Now two timers add one each, every second. We checked: with Strict Mode off, this clock started one interval and counted 1, 2, 3.

The fix is to keep the ID and return a cleanup that stops it:

import { useEffect, useState } from 'react'

export default function App() {
  const [seconds, setSeconds] = useState(0)

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

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

Run it. Now it counts 1, 2, 3, one each second. Strict Mode still started two timers, but the cleanup stopped the first one at once.

The missing cleanup also matters after the component is gone. We removed the clock with no cleanup from the page, and waited 2.1 seconds. Its interval still ran 2 times. With Strict Mode on, there were two timers, so they ran 4 times. With the cleanup, they ran 0 times.

Memory that stays in use after nobody needs it is a memory leak. A forgotten timer causes one: it keeps its function, and that function keeps the old component’s data. One forgotten timer is small. But if a timer is left behind every time a user opens a page, the leak grows.

setTimeout works the same way. Keep its ID, and return () => clearTimeout(id).

Cleaning up an event listener

In Part 6 you used onClick and other props for events on your own tags. Some events don’t belong to any tag you render. A key press anywhere on the page, or the window changing size, happens on the window object. To hear those, you call window.addEventListener. A function that waits for an event like this is called a listener.

import { useEffect, useState } from 'react'

export default function App() {
  const [lastKey, setLastKey] = useState('none yet')

  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      setLastKey(e.key)
    }
    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [])

  return <p>Last key: {lastKey}</p>
}

Press Run. Click inside the result box first, so it gets your key presses. Then press the A key. The page shows “Last key: a”.

The setup adds the listener. The cleanup removes it. The same pattern works for 'resize', 'scroll' and other window events.

Remove the same function you added

removeEventListener doesn’t remove “a listener that looks like this one”. It removes one exact function. The browser finds the listener by the event name and the function itself. If you pass a different function, nothing is removed, and no error appears.

Here is a mistake that looks right:

import { useEffect, useState } from 'react'

function KeyLogger() {
  useEffect(() => {
    window.addEventListener('keydown', () => console.log('key pressed'))
    return () => window.removeEventListener('keydown', () => console.log('key pressed'))
  }, [])

  return <p>Press a letter key.</p>
}

export default function App() {
  const [show, setShow] = useState(true)
  return (
    <div>
      <button onClick={() => setShow(!show)}>{show ? 'Hide' : 'Show'}</button>
      {show && <KeyLogger />}
    </div>
  )
}
key pressed
key pressed
key pressed
key pressed
key pressed
key pressed

The two arrow functions have the same code. But each () => ... makes a new function. So the cleanup asks to remove a function that was never added.

Press Run, click inside the result and press A once. Two lines appear: Strict Mode added two listeners and removed none. Now press “Hide”, then “Show”, then A again. Four lines appear. Hiding the component left its listeners behind, and showing it added two more.

We counted the listeners with Strict Mode on. After the first mount there were 2. After each Hide and Show there were 4, then 6, then 8. After the last Hide, all 8 were still there. With Strict Mode off, the count went 1, 2, 3, 4.

The fix is to give the function a name, and pass that same function to both calls:

import { useEffect } from 'react'

function KeyLogger() {
  useEffect(() => {
    function handleKeyDown() {
      console.log('key pressed')
    }
    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [])

  return <p>Press a letter key.</p>
}

Press Edit, change KeyLogger to this, and run it again. Each key press now prints one line, however many times you hide and show it. We checked: with the same function, there was always exactly 1 listener while it was shown, and 0 after Hide.

Cleaning up a subscription

To subscribe means to ask something to tell you when it changes. A chat, a store of data or a live price can all work like this. Most of them give you a way to unsubscribe, which means “stop telling me”.

Here is a tiny pretend chat. Its subscribe function gives back the unsubscribe function. It also prints how many listeners it has.

import { useEffect, useState } from 'react'

// A tiny pretend chat. Components can subscribe to hear new messages.
function createChat() {
  const listeners = new Set<(text: string) => void>()
  return {
    subscribe(listener: (text: string) => void) {
      listeners.add(listener)
      console.log('listeners: ' + listeners.size)
      return () => {
        listeners.delete(listener)
        console.log('listeners: ' + listeners.size)
      }
    },
    send(text: string) {
      listeners.forEach(listener => listener(text))
    },
  }
}

const chat = createChat()

function Messages() {
  const [last, setLast] = useState('nothing yet')

  useEffect(() => {
    return chat.subscribe(text => setLast(text))
  }, [])

  return <p>Last message: {last}</p>
}

export default function App() {
  const [show, setShow] = useState(true)
  return (
    <div>
      <button onClick={() => chat.send('Hello!')}>Send a message</button>
      <button onClick={() => setShow(!show)}>{show ? 'Hide' : 'Show'}</button>
      {show && <Messages />}
    </div>
  )
}
listeners: 1
listeners: 0
listeners: 1
listeners: 0

return chat.subscribe(...) subscribes, and returns the unsubscribe function as the cleanup. That is a short way to write the setup and the cleanup together.

Run it, press “Send a message”, then “Hide”. The first three lines are Strict Mode’s test: subscribe, unsubscribe, subscribe. The last line comes from Hide. The count goes back to 0, so the chat no longer keeps the removed component.

Without the return, we measured the opposite. With Strict Mode on, the chat had 2 listeners after the first mount, and one message called both of them. After each Hide and Show, it had 4, then 6, then 8.

React also has a hook made for reading from a store like this, useSyncExternalStore. You still write a subscribe function that returns the unsubscribe function, and React calls them for you. For this part, the plain Effect shows the idea.

Cleaning up a request: race conditions

A request to a server takes time. While you wait, the user may ask for something else. Then two answers are on the way. If the old answer comes back last, it can replace the new one.

This is a race condition: the result depends on which answer wins the race. Here is one. The playground can’t reach a real server, so fetchUser pretends to be one. User 1 takes 3 seconds. User 2 takes half a second.

import { useEffect, useState } from 'react'

type User = { id: number; name: string }

// A pretend server. User 1 is slow (3 seconds). User 2 is fast (half a second).
function fetchUser(id: number): Promise<User> {
  console.log('start ' + id)
  return new Promise(resolve => {
    setTimeout(() => {
      console.log('answer ' + id)
      resolve({ id, name: 'User ' + id })
    }, id === 1 ? 3000 : 500)
  })
}

function Profile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null)

  useEffect(() => {
    fetchUser(userId).then(result => setUser(result))
  }, [userId])

  return <p>Showing: {user ? user.name : 'Loading...'}</p>
}

export default function App() {
  const [userId, setUserId] = useState(1)
  return (
    <div>
      <button onClick={() => setUserId(1)}>User 1</button>
      <button onClick={() => setUserId(2)}>User 2</button>
      <p>You asked for user {userId}.</p>
      <Profile userId={userId} />
    </div>
  )
}
start 1
start 1
start 2
answer 2
answer 1
answer 1

Press Run, then press “User 2” at once, within 3 seconds. Guess what the page shows after a few seconds.

First it shows User 2, which is right. Then, about 3 seconds after you pressed Run, it changes to User 1. But you asked for user 2. The slow answer for user 1 came back last, and it won.

The log shows two requests for user 1. That is Strict Mode’s extra setup again. Both answered, and both called setUser.

Fix 1: ignore the old answer

Each run of the Effect has its own ignore variable. The cleanup sets it to true. When an old answer arrives, its Effect has already been cleaned up, so the answer is ignored. This is the pattern React’s docs use.

import { useEffect, useState } from 'react'

type User = { id: number; name: string }

// A pretend server. User 1 is slow (3 seconds). User 2 is fast (half a second).
function fetchUser(id: number): Promise<User> {
  console.log('start ' + id)
  return new Promise(resolve => {
    setTimeout(() => {
      console.log('answer ' + id)
      resolve({ id, name: 'User ' + id })
    }, id === 1 ? 3000 : 500)
  })
}

function Profile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null)

  useEffect(() => {
    let ignore = false
    fetchUser(userId).then(result => {
      if (!ignore) setUser(result)
    })
    return () => {
      ignore = true
    }
  }, [userId])

  return <p>Showing: {user ? user.name : 'Loading...'}</p>
}

export default function App() {
  const [userId, setUserId] = useState(1)
  return (
    <div>
      <button onClick={() => setUserId(1)}>User 1</button>
      <button onClick={() => setUserId(2)}>User 2</button>
      <p>You asked for user {userId}.</p>
      <Profile userId={userId} />
    </div>
  )
}

Run it and press “User 2” at once. The page shows User 2 and stays on User 2.

The fake server still prints the same six lines as before. All three requests start, and all three answer. The flag doesn’t stop the work. It only stops an old answer from changing the page. React’s docs put it this way: “You can’t “undo” a network request that already happened”.

Fix 2: stop the request with AbortController

Sometimes you want to really stop the request. Then the browser stops waiting for the answer and stops downloading it. The server may still finish its own work. Browsers have a tool for this, AbortController. To abort means to stop something before it finishes.

It has two parts:

  • controller.signal is an object you hand to the request.
  • controller.abort() tells everyone holding that signal to stop.

The browser’s fetch function takes a signal: fetch(url, { signal }). When you call abort(), fetch stops, and its promise fails with an error named AbortError. Our pretend server copies that. It listens for the signal’s 'abort' event, stops its timer, and fails with an AbortError of its own. new DOMException(message, 'AbortError') makes the same kind of error that fetch uses.

import { useEffect, useState } from 'react'

type User = { id: number; name: string }

// A pretend server that can be stopped, like fetch(url, { signal }).
function fetchUser(id: number, signal: AbortSignal): Promise<User> {
  console.log('start ' + id)
  return new Promise((resolve, reject) => {
    function stop() {
      clearTimeout(timer)
      console.log('stop ' + id)
      reject(new DOMException('The request was stopped.', 'AbortError'))
    }
    const timer = setTimeout(() => {
      signal.removeEventListener('abort', stop)
      console.log('answer ' + id)
      resolve({ id, name: 'User ' + id })
    }, id === 1 ? 3000 : 500)
    signal.addEventListener('abort', stop)
  })
}

function Profile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null)

  useEffect(() => {
    const controller = new AbortController()
    fetchUser(userId, controller.signal)
      .then(result => setUser(result))
      .catch(error => {
        if (error.name !== 'AbortError') throw error
      })
    return () => controller.abort()
  }, [userId])

  return <p>Showing: {user ? user.name : 'Loading...'}</p>
}

export default function App() {
  const [userId, setUserId] = useState(1)
  return (
    <div>
      <button onClick={() => setUserId(1)}>User 1</button>
      <button onClick={() => setUserId(2)}>User 2</button>
      <p>You asked for user {userId}.</p>
      <Profile userId={userId} />
    </div>
  )
}
start 1
stop 1
start 1
stop 1
start 2
answer 2

Run it, press “User 2” at once, and read the log.

  • The first start 1 and stop 1 are Strict Mode’s test. The cleanup stopped the first request right away.
  • The second start 1 is the real setup. Pressing “User 2” changed userId, so React ran the cleanup. It stopped that request too: stop 1.
  • Then start 2 and answer 2. Only one answer ever arrived.

The .catch matters. A stopped request fails with AbortError, and that is expected, so we ignore it. Any other error is a real problem, so we throw it again.

The pretend server also removes its own 'abort' listener when the answer comes. That is the listener rule from above, inside the server. An answer that has already arrived can’t be stopped, so there is nothing left to listen for.

Which fix should you use? The ignore flag works with any promise. AbortController also saves work, but only if the thing you call accepts a signal. fetch does. Many Effects use both ideas. Part 13 is about fetching data, and it builds on this.

Cleaning up changes to the page

Most of the page belongs to React. But an Effect can change things React doesn’t manage. Two common ones are the page title and animations.

The title of the browser tab lives in document.title. If an Effect changes it, the cleanup can put the old title back:

import { useEffect } from 'react'

function Cart({ items }: { items: number }) {
  useEffect(() => {
    const oldTitle = document.title
    document.title = `(${items}) My shop`
    return () => {
      document.title = oldTitle
    }
  }, [items])

  return <p>Items: {items}</p>
}

We checked this one too. The title was “My shop”. With 2 items, the title became “(2) My shop”. When items changed to 3, it became “(3) My shop”. When the component was removed, it went back to “My shop”.

An animation loop is like a timer. requestAnimationFrame(fn) asks the browser to run fn before it next draws the screen. A loop asks again from inside fn, so it keeps running. cancelAnimationFrame(id) stops it.

import { useEffect } from 'react'

function Spinner() {
  useEffect(() => {
    let id = 0
    function frame() {
      // move something a little here
      id = requestAnimationFrame(frame)
    }
    id = requestAnimationFrame(frame)
    return () => cancelAnimationFrame(id)
  }, [])

  return <p>Spinning...</p>
}

Notice that id changes on every frame. The cleanup reads it when it runs, so it stops the newest request. In our test page, we removed a loop that had no cleanup. It kept running: about 30 more frames in half a second. With Strict Mode on, it was about 60, because two loops were running. With cancelAnimationFrame, it ran 0 more frames.

What about setting state after the component is gone?

Older React printed a warning when you set state on a component that was already removed. In React 17, it started with “Can’t perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application.” A no-op is code that does nothing. Many lessons online still show code written to avoid that warning.

React 18 removed it. React’s list of changes for version 18.0.0 has a line for it: “No warning about setState on unmounted components”. It says the warning was added for subscriptions. Then it says: “but people primarily run into it in scenarios where setting state is fine”.

We checked React 19.3, with Strict Mode on and off. We removed a component at once. Shortly after, its timer called a set function. React printed nothing, not even a warning.

So setting state on a removed component is not the problem. React just ignores it. The real problem is work that keeps running: the timer, the listener, the subscription. Clean those up, and the late set calls stop too.

Common mistakes

Forgetting the cleanup

import { useEffect } from 'react'

function Clock() {
  useEffect(() => {
    setInterval(() => console.log('tick'), 1000)
  }, [])
  return null
}

What goes wrong: the interval never stops. In development, Strict Mode makes two of them at the same time. After the component is removed, it keeps going.

The fix: keep the ID and return () => clearInterval(id). Ask yourself after every setup: “What did I start, and what stops it?”

Returning something that isn’t a function

An arrow function with no curly braces returns its value. That can return something by accident:

import { useEffect, useState } from 'react'

export default function App() {
  const [show, setShow] = useState(false)
  useEffect(() => window.setTimeout(() => setShow(true), 1000), [])
  return <p>{show ? 'Hello!' : 'Wait...'}</p>
}

window.setTimeout returns a number, so this Effect returns a number. TypeScript stops you: Type 'number' is not assignable to type 'void | Destructor'. Destructor is the name React’s types use for a cleanup function.

The playground doesn’t check types, so press Run. React prints two lines like this one in the Console, one for each setup. The number at the end is the timer’s ID, so it changes:

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

Then the result goes empty, and the playground shows the error destroy is not a function. In our test, Strict Mode’s extra cleanup tried to call the number as a function. That crashed the component. With Strict Mode off, the page showed, and the same error came when the component was removed.

The fix: use curly braces, and return a real cleanup.

import { useEffect, useState } from 'react'

export default function App() {
  const [show, setShow] = useState(false)
  useEffect(() => {
    const id = window.setTimeout(() => setShow(true), 1000)
    return () => window.clearTimeout(id)
  }, [])
  return <p>{show ? 'Hello!' : 'Wait...'}</p>
}

Returning null is a mistake too. React’s message for that one says: “return undefined (or nothing)”.

Removing a different function

import { useEffect } from 'react'

function Bad() {
  useEffect(() => {
    window.addEventListener('resize', () => console.log('resized'))
    return () => window.removeEventListener('resize', () => console.log('resized'))
  }, [])
  return null
}

What goes wrong: the cleanup makes a new function, so nothing is removed. The number of listeners keeps growing, as you saw in the key logger.

The fix: name the function once, inside the Effect, and pass that name to both calls.

Making the Effect function async

import { useEffect, useState } from 'react'

export default function App() {
  const [name, setName] = useState('')
  useEffect(async () => {
    const result = await Promise.resolve('Ana')
    setName(result)
  }, [])
  return <p>Name: {name}</p>
}

Part 11 showed this mistake. An async function always returns a promise, not a cleanup function. So TypeScript refuses it, and the playground crashes with destroy is not a function, as in the mistake above.

There is a second problem, about cleanup. A cleanup written inside an async Effect never runs. We tested one: its setup ran, but its cleanup never did.

The fix: keep the Effect function normal. Put the async function inside it and call it. The cleanup goes in the normal function:

import { useEffect, useState } from 'react'

export default function App() {
  const [name, setName] = useState('')
  useEffect(() => {
    let ignore = false
    async function load() {
      const result = await Promise.resolve('Ana')
      if (!ignore) setName(result)
    }
    load()
    return () => {
      ignore = true
    }
  }, [])
  return <p>Name: {name}</p>
}

Practice

Press Edit on the examples above and try these. Remember that Strict Mode is on in the playground.

  1. In the first clock (the one with no cleanup), add return in front of setInterval. Does that fix the clock? Guess first. Hint: what does setInterval return?
  2. In the subscription example, change return chat.subscribe(...) to just chat.subscribe(...). Run it, then press “Hide” and “Show” three times. What is the last line in the Console?
  3. Run the AbortController example and press “User 2”. When the page shows User 2, press “User 1”, then quickly press “User 2”. Which new lines appear in the Console?
  4. In “Try this first”, delete the line return () => connection.disconnect(). Run it and press “Go to games”, then “Leave”. How many connections are still open at the end?
Answers
  1. No, it crashes. setInterval returns a number, so the Effect now returns a number, not a function. React prints useEffect must not return anything besides a function. Then the result goes empty, with destroy is not a function. This is the “Returning something that isn’t a function” mistake. To fix the clock, keep the ID and return () => clearInterval(id).
  2. listeners: 8. Strict Mode subscribes twice on each mount, and nothing ever calls the unsubscribe function. The first mount gives 1 and 2. Each Hide and Show adds 2 more: 4, 6, 8. Hide prints nothing, because there is no cleanup to call.
  3. Four lines: start 1, stop 1, start 2, answer 2. Pressing “User 1” starts a request for user 1. The cleanup for user 2 runs too, but that request had already answered, so nothing prints for it. Pressing “User 2” aborts the request for user 1, and starts one for user 2. There is no extra Strict Mode pair here, because the component was not added again.
  4. Three. The Console shows connect to music twice, then connect to games, and no disconnect lines at all. Strict Mode’s extra setup left one music connection open. The real setup opened another. Then “Go to games” opened a third. With the cleanup line, the answer is 0.

Interview questions

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

What is the cleanup function in useEffect, and when does React call it?

It is the function the setup function returns. It should stop or undo what the setup started. React calls it in two cases. The first is when a dependency changed. Then the old cleanup runs with the old values, and the new setup runs with the new values. The second is when the component is removed from the page. It doesn’t run on a re-render where no dependency changed.

A strong answer adds two details. In development, Strict Mode runs one extra cleanup and setup right after the component is added. And React runs the old cleanup after it has already changed the page, not before.

Why does React run Effects twice in development? Should you turn Strict Mode off?

It is Strict Mode. When a component is added, React runs setup, then cleanup, then setup again, in development only. It checks that your cleanup really stops what your setup started. If it does, the user can’t tell the difference. If it doesn’t, you see the bug at once: two timers, two listeners, two connections.

No, keep Strict Mode on, and write the cleanup. A strong candidate also says why a “has it run already?” flag is the wrong fix. It hides the double run, but the leak is still there. It shows up when the component is removed and added again. For example, the user leaves a page and comes back.

What is a memory leak in a React component? Give examples.

Memory that stays in use after nobody needs it. There are three usual causes. An interval is never cleared. A window listener is never removed. A subscription never ends. Each one keeps a function alive, and that function can keep the component’s data alive too. Every time the component is added again, one more piece is left behind.

A strong answer gives the fix for each one: clearInterval, removeEventListener with the same function, and the unsubscribe function. It also notes that setting state on a removed component is not itself the leak. React 18 removed the warning about it.

What is a race condition in data fetching, and how do you prevent it?

You start request A, then request B. Then A answers after B, and both call the set function. The page now shows A’s data, but the user asked for B. It happens when a dependency, like a user ID, changes faster than the server answers.

There are two fixes. The first is an ignore flag. Each Effect has its own flag. The cleanup sets it to true, so an old answer is dropped. The second is AbortController: the cleanup calls abort(), so the old request stops. A strong answer says that the flag only ignores the answer. Aborting also stops the browser from waiting for the answer and downloading it. It may add that React’s docs suggest a framework’s own data fetching, which avoids these problems.

How do you stop a fetch request when the Effect is cleaned up?

You make a controller with new AbortController(). You pass controller.signal to fetch(url, { signal }). Later, controller.abort() stops the request. The promise from fetch then fails with an error named AbortError. In an Effect, you call abort() in the cleanup. In .catch, you ignore AbortError but throw other errors again.

A strong candidate adds that a signal works only once. After abort(), any new fetch with that same signal fails at once. That is why each run of the Effect makes a new controller.

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

An async function always returns a promise. React expects the setup to return either nothing or a cleanup function. So React prints useEffect must not return anything besides a function, which is used for clean-up. A cleanup written inside the async function never runs. In development with Strict Mode, the extra cleanup then crashes with destroy is not a function. Without Strict Mode, the same crash comes later, when the component is removed or a dependency changes.

The fix is to write a normal setup function, put an async function inside it, and call it. The cleanup, often an ignore flag or an abort(), goes in the normal function.

Why must the cleanup remove the exact listener function that the setup added?

The browser finds the listener to remove by the event name and the function itself. Two arrow functions with the same code are still two different functions. If you pass a new function, nothing is removed, and the browser gives no error. So name the function once inside the Effect and pass that name to both calls.

A strong answer adds one more detail from the browser’s rules. If you added the listener with capture: true, you must also remove it with capture: true.

Does React warn when you set state on a component that was removed?

Not since React 18. Older versions printed “Can’t perform a React state update on an unmounted component”. React 18’s list of changes says it removed that warning. People mostly saw it in cases where setting state was fine. In React 19.3, a late set call on a removed component prints nothing.

A strong candidate says that you no longer need “is it still mounted?” checks just to quiet that warning. You still need cleanup functions to stop the work that causes the late call.

Sources

  • useEffect, react.dev: the setup and cleanup order (“After every commit with changed dependencies”), “The cleanup function should stop or undo whatever the setup function was doing”, the “stress-test” in development, the ignore flag for fetching, framework data fetching, and the s => s + 1 style of updater in an interval.
  • Synchronizing with Effects, react.dev: the patterns for listeners, animations and fetching, “You can’t “undo” a network request that already happened”, and the warning against using a ref to stop the second setup.
  • Lifecycle of Reactive Effects, react.dev: how an Effect stops and starts again when a dependency changes.
  • StrictMode, react.dev: why Effects run an extra time in development, and that the checks are development-only.
  • useSyncExternalStore, react.dev: React’s hook for subscribing to an outside store.
  • React 18.0.0 changelog and pull request #22114: “No warning about setState on unmounted components”, and why it was removed.
  • React 17.0.2’s ReactFiberWorkLoop.old.js: the text of the old warning, “Can’t perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application.”
  • MDN: removeEventListener (a listener is found by its type, the function itself and the capture flag), AbortController and AbortSignal (fetch fails with AbortError; a signal can be used once), setInterval, clearInterval and cancelAnimationFrame.
  • The orders of setup and cleanup calls, the clock counts, the listener and subscriber counts, the request logs, the warning texts, the frame counts and the title changes come from running React 19.3.0 for this post, with Strict Mode on and off. The TypeScript errors come from TypeScript 7.0.2.
  • This part follows the useEffect Cleanup 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.