Why React useEffect Callbacks Should Never Be Async Functions
An async function passed to useEffect breaks React's cleanup contract because it returns a Promise. Four correct patterns for async work inside effects.
This article is published in English.
This question surfaced during a frontend interview at Paytm, and it deserves more credit than its simplicity suggests. On the surface it reads like a minor syntax gotcha. In reality it probes whether you grasp the effect lifecycle, how JavaScript Promises actually behave, what React internally does with whatever your effect hands back, and how to fetch data the right way inside a component.
Most candidates offer a response that is technically true but only scratches the surface:
"useEffect expects you to return a cleanup function, and an async function returns a Promise instead."
That's a reasonable opening line, but it leaves the interesting questions unanswered. What does React do with that Promise once it receives it? Does it simply discard it? What actually breaks as a result? And, more usefully, how should you structure async logic inside an effect so nothing breaks?
Let's construct the full picture piece by piece, cross-checking each claim against the React documentation and the React source itself.
Part 1: The contract useEffect expects
The function passed into useEffect is officially called the setup function, and React's documentation is strict about what it's allowed to return. There are only two acceptable outcomes:
- Returning nothing, meaning the value is
undefined, or - Returning a cleanup function that React will invoke at a later point.
useEffect(() => {
// setup: runs after the component is committed to the screen
const id = setInterval(tick, 1000);
// cleanup: React calls this before the next setup run,
// and one final time when the component unmounts
return () => clearInterval(id);
}, []);
That cleanup function isn't a nice-to-have. It's the mechanism React relies on to dismantle whatever the setup phase created, whether that's a timer, a subscription, an event listener, or an open WebSocket connection. Per the React documentation, React invokes your cleanup function right before re-running the effect, and once more when the component unmounts for good.
Here's the detail that makes this whole question matter: React needs that cleanup function immediately, the instant your setup function finishes executing. Sometimes cleanup has to happen almost right away, potentially before the first await inside an async function would even settle. A cleanup function that shows up "eventually" is one React has no way to use.
Part 2: Why async always means Promise
This section is plain JavaScript behavior, and it holds no exceptions. Per MDN, functions declared with async always return a Promise, full stop. Even if you return something that isn't already a Promise, JavaScript silently wraps it in one for you. There's no syntax that lets you sidestep this.
async function getCleanup() {
return () => console.log('cleaning up');
}
const result = getCleanup();console.log(result);
// Promise { <fulfilled>: f } <- the function is trapped insideconsole.log(typeof result);
// "object" <- not "function"
Look closely at what happened there. The code explicitly returns an arrow function, and yet the caller still doesn't receive a function. Instead, they get a Promise that will eventually resolve to that function. This holds true even for an async function with no return statement at all — it still hands back a Promise, this one resolving to undefined.
Put the two pieces together:
- React expects either
undefinedor an actual function, delivered synchronously. - An async function always delivers a Promise, no matter what.
So the contract is already violated before any of your effect logic has a chance to run.
// What you think you wrote:
useEffect(async () => {
const data = await fetchUser(id);
setUser(data);
return () => controller.abort(); // "my cleanup"
}, [id]);
// What React receives from your setup function:
// Promise { <pending> }
// Your cleanup is locked inside a box React never opens.
Part 3: What React actually does with your return value
This is where a lot of explanations go astray. It's commonly said that "React just ignores the Promise you return." The source code tells a different story. React actually performs three separate actions with that returned value, and two of them cause real problems.
3.1 It stores the Promise as your cleanup
Deep inside the reconciler, specifically in commitHookEffectListMount — the function responsible for running your effects after a commit — React invokes your setup function and stores the return value directly, no questions asked. In a production build, there's no runtime check to catch a mismatched type:
// react-reconciler/src/ReactFiberCommitEffects.js (simplified)
const create = effect.create; // your setup function
const destroy = create(); // whatever it returns...
inst.destroy = destroy; // ...is stored as the cleanup
If the setup function you passed was async, inst.destroy now holds a pending Promise sitting in the exact spot where React expects a callable cleanup function.
3.2 The development build gives you a very specific warning
Once the value is stored, development builds run an extra validation step. React checks whether typeof destroy !== 'function', and it goes further than a generic type check — it specifically looks for a .then method on the returned value. That's a dead giveaway that you handed it a Promise instead of a function, and React calls it out directly:
useEffect must not return anything besides a function, which is used for
clean-up.
It looks like you wrote useEffect(async () => ...) or returned a Promise.
Instead, write the async function inside your effect and call it immediately:useEffect(() => {
async function fetchData() {
// You can await here
const response = await MyAPI.getData(someId);
// ...
}
fetchData();
}, [someId]); // Or [] if effect doesn't need props or stateLearn more about data fetching with Hooks: https://react.dev/link/hooks-data-fetching
Notice that the warning doesn't just complain — it hands you the fix inside the message itself, showing you the pattern of declaring an async function inside the effect and invoking it right away. When a framework's own error text already contains the solution, that's usually a strong signal that interviewers expect you to have seen it before.
3.3 When cleanup time comes, React tries to invoke the Promise
Here's where things get genuinely dangerous. When the effect's dependencies change, or the component unmounts, React's unmount logic only checks whether something was stored as a cleanup — it never verifies that the stored value is callable:
// commitHookEffectListUnmount (simplified)
const destroy = inst.destroy;
if (destroy !== undefined) {
safelyCallDestroy(effect, ...); // executes destroy()
}
// destroy is a Promise here, and a Promise is not callable:
// TypeError: destroy is not a function
Trying to call a Promise as though it were a function throws a TypeError. This is exactly what produced the well-known "Uncaught TypeError: destroy is not a function" crash in React 17. In current React versions, this call is wrapped in a protected path, so instead of crashing the entire page, the error is caught as a commit-phase error and forwarded to the closest error boundary. Either way, this is a genuine runtime failure, not just a console message you can shrug off.
Put together, you end up with three distinct failures:
- Your actual cleanup code never executes, so timers, event listeners, and subscriptions are never torn down.
- React logs a warning while you're developing.
- Later, when React attempts to run "cleanup," it tries to call a Promise as a function and throws.
An async setup function doesn't quietly slip past the rules — it violates them at two separate points in the component lifecycle.
Part 4: Comparing the two execution paths step by step
It helps to line up a synchronous setup function against an async one, moment by moment. The first three steps match exactly. Everything diverges starting at step four.
Synchronous version:
- The component function executes and JSX is computed.
- React commits the update, and the browser paints.
- React invokes your setup function.
- The function returns an actual cleanup function, which React stores.
- Any async logic inside the effect keeps running, and state updates apply without issue.
- When dependencies change or the component unmounts, React calls the stored
cleanup(). - The cleanup clears the interval, removes the listener, or flips the ignore flag — nothing leaks.
Async version:
- The component function executes and JSX is computed.
- React commits the update, and the browser paints.
- React invokes your setup function, which — being async — returns instantly.
- What comes back is a
Promise { <pending> }, and React stores that Promise as if it were the cleanup. In development, the warning fires here. - Your
awaitcalls are still resolving somewhere in the background, and whatever cleanup logic you wrote is buried inside that unresolved Promise, unreachable. - When dependencies change or the component unmounts, React tries to call the stored value — which isn't a function — and throws a
TypeError. - No cleanup ever ran. Intervals keep firing, and outdated responses can still trigger
setState.
This comparison also clarifies why the contract demands something synchronous. Effects fire after the commit, once the paint has already happened, and the cleanup for the previous effect must run right before the next effect fires — which can be as soon as the following render. If React had to wait on a Promise just to determine whether a cleanup function exists, the whole commit sequence would need to pause on arbitrary user code, which conflicts with how the commit phase is designed to work. That's the real reason the API was built around synchronous returns rather than around teaching React to await your Promises.
Part 5: The race condition this pattern conceals
There's a second, subtler danger to the async pattern: without a cleanup function, you lose any way to cancel or disregard outdated asynchronous work. React's own documentation illustrates this using a profile-page scenario, and it's worth understanding well, because this isn't a stylistic nitpick — it's a bug you can ship to production.
Here's how it plays out:
- You click on "Bob," and the app starts fetching Bob's bio.
- Before that finishes, you click on "Taylor," triggering a fetch for Taylor's bio.
- Taylor's response comes back first, so the UI displays Taylor's bio.
- Bob's response, which was slower, arrives afterward and overwrites the state.
- The screen ends up showing Taylor as the selected user, but with Bob's bio displayed underneath.
Because network responses aren't guaranteed to resolve in the order the requests were fired, this kind of mismatch is a real risk. The standard fix is a cleanup function that sets an ignore flag, letting you discard every response except the one tied to the latest render. That solution only works if your setup function returns synchronously, giving React an actual cleanup callback to invoke.
So the async callback isn't merely a technical violation of an API contract — it strips away the very mechanism that keeps this race condition from reaching your users.
Part 6: The correct patterns
Every valid fix rests on the same idea: keep the setup function itself synchronous, and push the async work into something that function calls. That way React still gets a real cleanup callback out of the deal.
Pattern 1: A named async function inside the effect (the official recommendation)
This is the approach shown in the React documentation, and it includes the ignore flag that neutralizes the race condition described above:
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
} startFetching(); return () => {
ignore = true;
};
}, [userId]);
The outer arrow function never touches await, so return () => { ignore = true; } gives React a genuine cleanup function to hold onto. The inner named function, meanwhile, is free to await whatever it needs.
Pattern 2: An async IIFE
This does the same job, just with an anonymous function that fires immediately instead of a named one:
useEffect(() => {
let ignore = false;
(async () => {
const data = await fetchUser(id);
if (!ignore) setUser(data);
})(); return () => { ignore = true; };
}, [id]);
Pattern 3: Plain .then() chains
The React reference documentation actually uses this style in its own data-fetching example:
useEffect(() => {
let ignore = false;
setBio(null);
fetchBio(person).then(result => {
if (!ignore) {
setBio(result);
}
});
return () => {
ignore = true;
};
}, [person]);
Pattern 4: AbortController, when you want real cancellation
The ignore flag stops a stale response from overwriting state, but it doesn't stop the request itself from running to completion in the background. When you're using fetch, you can go further and cancel the network call outright. The React docs are explicit that cleanup should either abort the fetch or ignore its result:
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const res = await fetch(`/api/users/${id}`, {
signal: controller.signal,
});
setUser(await res.json());
} catch (e) {
if (e.name !== 'AbortError') throw e;
}
})(); return () => controller.abort();
}, [id]);
Which of these should you lead with in an interview? Start with Pattern 1 — it's the one both React's dev-mode warning and the official docs point to. Name the ignore flag explicitly, then add that for fetch calls specifically, you'd go a step further and use AbortController to cancel the request rather than just discard its result. That combination — correctness, the race condition, and wasted network traffic — fits into three sentences and covers the whole problem.
Part 7: StrictMode and TypeScript catch this too
StrictMode. In development, StrictMode deliberately runs an extra setup-then-cleanup cycle right after the initial mount. According to the React docs, this is meant to stress-test whether your cleanup logic truly mirrors your setup logic. An async effect is bound to fail that check by its very nature, since it never hands back a usable cleanup function. This forced double invocation is exactly what exposes duplicated subscriptions, duplicated intervals, and duplicated fetches whose two responses both end up written into state.
TypeScript. The type definitions for React bake this contract directly into the type system:
// From @types/react (simplified)
type Destructor = () => void;
type EffectCallback = () => void | Destructor;
function useEffect(effect: EffectCallback, deps?: DependencyList): void;
An async function has the signature () => Promise<void>, and that type simply isn't assignable to EffectCallback. So in a TypeScript project, this mistake never even makes it past compilation. That's worth noting in itself: the restriction is fundamental enough that it's enforced in three separate places — the type system, the development-mode warning, and the actual runtime behavior.
Part 8: Modern alternatives worth mentioning
A stronger answer also acknowledges that hand-rolled fetching inside effects isn't considered best practice anymore. The React docs themselves note that fetching data directly inside effects becomes repetitive and makes caching and server rendering harder to add later. They point to a few alternatives instead:
- Framework-level loaders. If the project uses a framework like Next.js or Remix, its built-in data-fetching mechanism is preferable to a client-side effect.
- Client-side cache libraries. The React docs specifically call out TanStack Query, useSWR, and React Router 6.4+. These already handle deduplication, caching, and the exact race conditions discussed above.
- The
useAPI introduced in React 19.use(promise)lets a component read a Promise's resolved value directly, suspending the component while the Promise is pending and letting Suspense render a fallback. One important caveat worth mentioning if asked: a Promise created fresh during render gets recreated on every render, souseis meant to consume promises handed down from Server Components or managed by a cache — not a rawfetch()call written inline.
None of this replaces the core explanation. The question about the effect contract is really testing whether you understand what React does internally with your return value. But closing with something like "and that's why, for real data fetching, you'd reach for TanStack Query or a framework loader instead" signals that you also know where current best practice actually stands.
The 30-second interview answer
If you keep only one version of this answer in mind, keep this one:
"useEffect's setup function follows a synchronous contract: it must return either nothing or a cleanup function, since React needs that cleanup ready to invoke right before the effect runs again and whenever the component unmounts. An async function can never meet that requirement, because async functions always hand back a Promise no matter what runs inside them. React then stores that Promise in the exact slot where a cleanup function belongs: in development it logs the 'It looks like you wrote useEffect(async () => …)' warning, the real cleanup logic never gets a chance to run, and later, when React tries to call the stored value as though it were a cleanup function, it throws a TypeError. In practice this shows up as leaked subscriptions and race conditions, because without a working cleanup there's no way to ignore or abort a stale request. The fix is to keep the callback itself synchronous, define an async function inside it, invoke that function, and hand back a cleanup that either sets an ignore flag to true or triggers the abort method on an AbortController. For real-world data fetching, a framework loader or a library like TanStack Query is the better choice, since those already handle these race conditions automatically."
This answer touches the contract, the underlying JavaScript rule, both concrete failure modes, the race condition, the accepted fix, and where the modern ecosystem has moved. It signals to an interviewer that the concept wasn't just memorized as a rule — the mechanism behind it is understood, which is precisely what a question like this is meant to reveal.