In React, why does passing an async function directly to useEffect break, and how do you await something inside an effect instead?
answer
- the return slot has one job
- async functions always return something
- a Promise is not a cleanup function
- you can await inside, not outside
- declare the async function within
basics
~20 sThe return value of a useEffect callback is reserved for the cleanup function, and an async function always returns a Promise, so React warns and you lose cleanup. Declare an async function inside the effect and call it.
solid answer
~50 s`useEffect` gives the setup function's return value exactly one meaning: it is the cleanup function React will call before the next run and at unmount. An `async` function always returns a Promise, so writing `useEffect(async () => { ... }, [])` hands React a Promise where it expects a function or `undefined`, and React warns that an effect must not return anything besides a function used for clean-up. It is not just a lint complaint — you have spent the only slot you had for cleanup, so nothing can undo what the effect started. The fix is to keep the effect callback synchronous and put the async work in a function declared inside it: `useEffect(() => { async function run() { ... } run(); return () => { /* cleanup */ }; }, [])`. The `return` slot stays free for the cleanup you actually need.
code
javascript · 23 linesimport { useEffect, useState } from 'react';
function User({ id, loadUser }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
async function run() {
const data = await loadUser(id);
if (!cancelled) setUser(data);
}
run();
return () => {
cancelled = true;
};
}, [id, loadUser]);
return null;
}
export default User;go deeper
Remember the shape: never mark the useEffect callback itself async. Put the async function inside the effect and call it. Be able to say the return value is reserved for the cleanup function.
Explain the mechanism — an async function returns a Promise, React expects a function or undefined, and the warning is about the return type. Show the corrected form with an inner async function and a real cleanup returned from the outer callback.
Go past the warning to the consequence: losing the return slot means losing the undo path for anything the effect started. Talk about what your team's effect bodies must guarantee so that late-resolving work cannot act on a component whose inputs have moved on.
Frame it as an API-contract question: React reserved one narrow channel for teardown, and any convention your codebase adopts must keep it free. Be ready to argue for lint enforcement and for moving async work out of effects entirely where the architecture allows.
## The contract on the return value `useEffect(setup, deps?)` treats the setup function's return value as a single, specific thing: a **cleanup function**. React calls it before running setup again on a later render, and once more when the component unmounts. Anything other than a function — or `undefined` — is a contract violation, and React says so in development: *"useEffect must not return anything besides a function, which is used for clean-up."* ```js useEffect(() => { const id = setInterval(tick, 1000); return () => clearInterval(id); // the one legal return }, []); ``` ## Why `async` collides with that contract An `async` function does not return whatever the body returns; it returns a Promise that settles with that value. So this call: ```js useEffect(async () => { const data = await loadUser(id); setUser(data); }, [id]); // React receives a Promise, not a cleanup function ``` hands React a Promise. React cannot call it, cannot know when it settles, and has no way to interpret it. Two consequences follow, and the second is the one interviewers care about: 1. **The warning.** Development builds log the message above, which is noise at best. 2. **You have no cleanup slot left.** The only channel for "undo what I started" is the return value, and an async function has spent it. There is nowhere left to clear a timer, unsubscribe, or mark the in-flight work as abandoned. Whatever the awaited work does when it finishes, it does unconditionally — including calling a state setter for a component that has already re-run the effect with different inputs. A subtler point: even if React did await the Promise, it would be waiting on your function to finish before it could get a cleanup, which would make cleanup timing dependent on network latency. There is no sensible semantic to give it, which is why the API refuses it rather than guessing. ## The idiomatic shape Keep the callback synchronous and declare the async function inside it: ```js useEffect(() => { let cancelled = false; async function run() { const data = await loadUser(id); if (!cancelled) setUser(data); } run(); return () => { cancelled = true; }; }, [id]); ``` The effect callback itself returns a plain function, so the contract is satisfied; the async work lives one level in. An equivalent form uses the Promise API directly — `loadUser(id).then(...)` — and returns the same cleanup. A third variant is an immediately invoked async function expression, `(async () => { ... })();`, which is the same trick without naming the function. All three are fine; the constant is that the value reaching React is a function or nothing. ## An async function is not the same as an async effect Candidates sometimes conclude that effects "cannot do asynchronous work". They can, and they routinely do. The rule constrains the **signature**, not the behaviour: the function you hand `useEffect` must be synchronous in its return, while everything it kicks off may be as asynchronous as you like. React simply does not track that work — it starts when the effect runs and it is your responsibility to make the cleanup path account for it. ## The same rule for useLayoutEffect `useLayoutEffect` has an identical setup/cleanup contract, and the same restriction applies for the same reason. Because layout effects run synchronously and block painting, handing one an async function is worse in practice: the awaited part necessarily resolves after paint, so the code reads as if it runs before paint while the important half does not. ## What TypeScript catches In a TypeScript codebase this is a compile error rather than a runtime warning, because the effect parameter is typed to return `void` or a destructor. That is a useful thing to mention: the error message points at the return type, not at the `async` keyword, which is why the fix is not obvious to someone who has not internalised what the return slot is for.
- If React just ignored the returned Promise, what would still be wrong with an async effect callback?You would still have no way to return a cleanup function, because the async signature occupies the only return channel. Anything the effect started — a subscription, a timer, an in-flight request whose result sets state — would have no undo path when the dependencies change or the component unmounts. The warning is a symptom; the missing cleanup is the actual defect.
- Is it acceptable to write the async work as an immediately invoked async function expression inside the effect?Yes. `(async () => { ... })();` inside a synchronous effect callback is equivalent to declaring and calling a named function, and the effect can still return its cleanup. Choose it for very short bodies; a named inner function reads better when the logic grows or when you want the name in a stack trace.
- Does the same restriction apply to useLayoutEffect?Yes — it has the same setup-and-cleanup contract, so its callback must also return a function or nothing. It matters more there in practice: useLayoutEffect runs before the browser paints, but anything you await necessarily resumes after paint, so an async layout effect reads as if it beats the paint when the meaningful half does not.
saying these in an interview costs you the question
- Says async effects are fine and the warning is cosmetic
- Thinks React awaits the returned Promise before continuing
- Concludes effects cannot perform asynchronous work at all
- Returns the fetch Promise itself as the cleanup
- Silences the warning instead of restoring a cleanup path