skip to content

In React, when you return a function from the callback you pass to useEffect, what does React do with that function and when does it call it?

level: juniorimportance: must knowfreq 80%

answer

  1. think undo, not finish
  2. it can run many times
  3. one teardown per setup
  4. fires before the next run
  5. and once more at unmount

basics

~20 s

React treats the returned function as that effect's cleanup. It calls the cleanup before running the effect again after a dependency change, and once more when the component unmounts, so every setup is torn down exactly once.

solid answer

~40 s

The returned function is the effect's cleanup, and React pairs it one-to-one with the run that created it. If the dependencies change, React first calls the previous run's cleanup and only then runs the effect body again; when the component unmounts, React calls the last run's cleanup. That pairing is what makes an effect safe to re-run: whatever this run started — a `setInterval`, a listener, an open connection — the cleanup that came back with it is the thing that stops it. The cleanup closes over the values of the render that created it, so it tears down the right resource rather than the current one. React expects a function or nothing back; if you pass an `async` callback the return value is a promise, not a cleanup, and React warns.

go deeper

for a junior

Say plainly that the returned function is the cleanup, and that React calls it before re-running the effect and again on unmount. Show the pattern with a timer: start it in the body, clear it in the returned function.

for a middle

Explain the one-to-one pairing — every setup gets exactly one cleanup, run before the next setup — and why the cleanup closes over the values of the render that created it, so it releases the resource that run acquired rather than the current one.

for a senior

Be ready to diagnose the production symptom: duplicated timers or handlers stacking up as a screen is re-entered, or callbacks firing after the component is gone. Show that you write the teardown in the same edit as the setup rather than adding it after a leak is reported.

for a principal

Frame it as an ownership rule for the codebase: any effect that acquires a resource must return its release, and review should reject the ones that do not. Talk about how you make the class of bug detectable — development remounting, lint enforcement, and leak checks — instead of relying on individual discipline.

## What the returned function is `useEffect` takes a setup function. Anything that function returns is treated as its **cleanup** — the instruction for undoing whatever this particular run set up. Returning nothing is legal and means "there is nothing to undo". Returning anything other than a function is a bug: React only knows how to call a function. ```js useEffect(() => { const id = setInterval(tick, 1000); // setup return () => clearInterval(id); // cleanup for THIS run }, [tick]); ``` ## The pairing rule The mental model that makes every other detail fall out: **each run of the effect produces at most one cleanup, and React calls that cleanup exactly once.** The effect is not a lifecycle hook that fires "on mount" and "on unmount"; it is a repeatable setup/teardown pair that React re-executes whenever the dependencies say the setup is out of date. So across a component's life you get a strictly alternating sequence: ``` setup(run 1) ... cleanup(run 1) -> setup(run 2) ... cleanup(run 2) -> setup(run 3) ... ``` React never runs two setups back to back without the cleanup in between, and it never leaves the final cleanup uncalled when the component goes away. ## When cleanup runs There are exactly two triggers. **1. Before the effect runs again.** After a re-render where at least one dependency changed by React's comparison, React calls the previous cleanup *first*, then runs the new setup. The order matters: the old subscription is closed before the new one opens, so you never briefly hold two. If nothing in the dependency array changed, React skips both — no cleanup, no setup, the previous run simply stays live. **2. On unmount.** When the component is removed from the tree, React calls the last cleanup that is still outstanding. This is what stops a timer or listener from outliving the component that created it. A consequence people get wrong in interviews: with an empty dependency array `[]` the cleanup is not dead code. It never runs *between* renders because the dependencies never change — but it still runs once, at unmount. In development, React deliberately mounts, unmounts and remounts a component once so that a missing or wrong cleanup shows up immediately as a doubled timer or a leaked listener; production runs the pair once. ## What the cleanup can see The cleanup is created inside the same call as the setup, so it closes over that render's variables — the `id` above is the id of *this* run's interval, not of the interval that is about to be created. That is precisely why teardown works when the effect re-runs: the outgoing cleanup refers to the outgoing resource. Never try to clean up "whatever is current"; clean up what this run created. ## The async trap ```js // wrong: an async function returns a promise, not a cleanup useEffect(async () => { const data = await load(); setData(data); }, []); ``` React receives a promise where it expected a cleanup function and warns that an effect must return either a function or nothing. Note the failure is silent in behaviour terms — the effect still runs, you just have no teardown. The fix is to declare the async function inside and call it: ```js useEffect(() => { let cancelled = false; (async () => { const data = await load(); if (!cancelled) setData(data); })(); return () => { cancelled = true; }; }, []); ``` ## Why the discipline pays Every effect that acquires something — a timer, a handle, a listener, an open connection — must hand back the matching release. If you omit it, each re-run stacks another live resource on top of the last, and unmount leaves the final one running against a component that no longer exists. The symptom is the classic one: work that gets faster and faster as a screen is revisited, or callbacks firing after a component has been closed. Writing the cleanup in the same breath as the setup, before you move on, is the habit that prevents the whole class of bug.

  • If the dependency array is empty, does the cleanup ever run?
    Yes — once, when the component unmounts. An empty array only means the dependencies never change, so React never re-runs the setup and therefore never needs an intermediate teardown. The final cleanup still fires on removal. In development React also mounts, unmounts and remounts once on purpose, so you will see an extra setup/cleanup pair there; that is a deliberate check, not a bug.
  • What order does React use when both a cleanup and a new setup are due after a dependency change?
    Cleanup first, then setup. React tears down the previous run completely before starting the next one, so you never hold the old and new resource at the same time. That ordering is what lets an effect that opens a connection per user id switch ids safely: the old connection is closed before the new one is opened.
  • Someone writes useEffect(async () => { ... }, []). What is wrong with it?
    An `async` function always returns a promise, so React gets a promise where it expects a cleanup function and warns. The effect body still runs, but you have no teardown at all. Declare the async work in an inner function and call it, then return a real cleanup — for example a flag the resolved code checks before touching state.

saying these in an interview costs you the question

  • Cleanup only runs when the component unmounts
  • Cleanup runs after the next effect run, not before
  • Returning an async function is fine, React awaits it
  • With an empty dependency array the cleanup never runs
  • Cleanup fires on every render regardless of dependencies

context