Skip to content

React warningReact: “useEffect must not return anything besides a function, which is used for clean-up”

Writing useEffect(async () => { … }) triggers useEffect must not return anything besides a function, which is used for clean-up, and TypeScript rejects it with a type error about EffectCallback. An async function always returns a Promise, and React expects an effect to return either nothing or a cleanup function.

Short answer

Keep the effect itself synchronous. Declare the async function inside it, call it right away, and return a cleanup that sets an ignore flag (or aborts the request), so a slow response from an earlier render can’t overwrite newer data. In Next.js App Router or other frameworks with server data loading, fetching in the framework is often simpler than in an effect.

Why an async effect breaks, and the fix

  1. 01

    The effect itself is async

    “You can also rewrite using the async / await syntax, but you still need to provide a cleanup function” React, useEffect

    The Promise an async effect returns is treated as its cleanup, which React can’t call. In development that gives the warning; in production there is no warning, and leaving the screen can crash with destroy is not a function. Move the async work into a function inside the effect.

    // warns
    useEffect(async () => {
      const res = await fetch(`/api/orders/${id}`);
      setOrders(await res.json());
    }, [id]);
    
    // works
    useEffect(() => {
      let ignore = false;
      async function load() {
        const res = await fetch(`/api/orders/${id}`);
        const data = await res.json();
        if (!ignore) setOrders(data);
      }
      load();
      return () => { ignore = true; };
    }, [id]);
  2. 02

    Responses that arrive out of order

    “This ensures your code doesn’t suffer from “race conditions”: network responses may arrive in a different order than you sent them.” React, useEffect

    Without cleanup, switching quickly from one id to another can show the older response last. The ignore flag, or an AbortController whose abort() runs in the cleanup, drops the stale one.

    useEffect(() => {
      const controller = new AbortController();
      fetch(`/api/orders/${id}`, { signal: controller.signal })
        .then((res) => res.json())
        .then(setOrders)
        .catch((err) => { if (err.name !== 'AbortError') setError(err); });
      return () => controller.abort();
    }, [id]);
  3. 03

    Errors that vanish

    “Your setup function may also optionally return a cleanup function.” React, useEffect

    An unawaited Promise that rejects inside an effect doesn’t reach an error boundary. Catch the error in the async function and put it in state, so the screen can show it. Any cleanup you return must be a function, never a Promise.

  4. 04

    Data fetching that belongs to the framework

    Effects run after the page renders, so data fetched there shows up late and isn’t in the server HTML. In Next.js, load data in a Server Component or the framework’s data functions; on the client, a data library such as TanStack Query handles caching and races for you.

Checked against the vendor’s docs on : React reference: useEffect (fetching data with Effects); React: Removing Effect Dependencies.

Rather hand it over?

Fixed scope, fixed price, paid upfront. The work happens in writing; no call needed.

Related: React “useEffect has a missing dependency” · React “Maximum update depth exceeded” · Next.js hydration error

Questions

More on this error

Can I use an IIFE instead?

Yes. (async () => { … })() inside the effect works the same as a named function you call. A named function is easier to read and to call again for a retry.

What about “Can’t perform a React state update on an unmounted component”?

That older warning came from the same pattern: a response arriving after the component left. React 18 removed the warning, but the ignore flag or AbortController above is still the fix for the wasted work.

Can you fix it for me?

If one bug is the whole problem, the App Fix Ticket covers it: a written diagnosis first, then the fix. If the app has more going on, the Launch Check is a written check of the whole app. Each issue found is rated critical, high, medium or low, with where it is and how it was confirmed. No issues worth fixing, no charge. If the Launch Check finds nothing rated medium or higher, Simon refunds the full $450. You keep the report either way. This applies to the first 10 Launch Checks bought after October 30, 2026.

A different error?

Describe it and get a written quote

Send the error and what you were doing. You get a written scope, a fixed price and a timeline; Simon replies by email, usually within one business day.