Skip to the solution
MediumFrontend6 min read

How do you handle asynchronous operations in React functional components?

930 views

The problem

Question presented to candidate: "Walk me through doing async work in a function component β€” and the bugs that come with it."

What a strong answer should cover:

  • Never make the effect callback itself async. It returns a promise where React expects a cleanup function, and React warns about it explicitly.
  • The correct shape: define an async function inside the effect and call it, then return a real cleanup.
  • Race conditions are the headline bug: responses can arrive out of order, so a slow earlier request can overwrite a newer one.
  • Two fixes: a cancellation flag (ignore the stale result) or AbortController (actually cancel the request). Prefer the latter β€” it frees the connection too.
  • Handle all three states β€” loading, error, success β€” and remember an aborted request rejects with an AbortError you should not surface as a failure.
  • Event handlers can be async freely; only the effect callback has the return-value constraint.
  • React 19: use() with Suspense for reading a promise, and useActionState/useTransition for async form submissions.
  • The honest recommendation: for server data, use a library β€” it solves caching, deduplication, and staleness, not just ordering.

Clarifying questions expected:

  • "Is this triggered by rendering, or by a user action?" β€” effect versus handler.
  • "Can we use a data library, or does this need to be hand-rolled?"

Code / implementation expected: Yes β€” the AbortController effect, and the race condition it prevents.

  • async
  • useEffect
  • data fetching
  • hooks

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

6 min read Β· 9 sections

Try it in the playground

Open any example in the playground to run and change it.

ReactThe race condition, unguarded and then fixed with AbortController
import { useState, useEffect } from "react";

// A fake API where LOW ids are deliberately SLOW, so an earlier request can
// land after a later one β€” exactly the shape of a real race condition.
function fetchUser(id, signal) {
  const delay = id === 1 ? 1200 : 200;
  return new Promise((resolve, reject) => {
    const t = setTimeout(() => resolve({ id, name: "User " + id }), delay);
    signal?.addEventListener("abort", () => {
      clearTimeout(t);
      const err = new Error("aborted");
      err.name = "AbortError";
      reject(err);
    });
  });
}

// ❌ No guard: whichever response arrives LAST wins, regardless of order asked.
function Unguarded({ id }) {
  const [user, setUser] = useState(null);
  useEffect(() => {
    setUser(null);
    fetchUser(id).then(setUser);
  }, [id]);
  return <Row label="unguarded" id={id} user={user} />;
}

// βœ… AbortController: the stale request is genuinely cancelled.
function Guarded({ id }) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    setUser(null);
    setError(null);

    // The async function is declared INSIDE. The effect callback itself stays
    // synchronous so its return value can be the cleanup function.
    async function load() {
      try {
        const data = await fetchUser(id, controller.signal);
        setUser(data);
      } catch (e) {
        // An abort is deliberate β€” do not surface it as a failure.
        if (e.name !== "AbortError") setError(e.message);
      }
    }
    load();

    return () => controller.abort();
  }, [id]);

  return <Row label="AbortController" id={id} user={user} error={error} />;
}

function Row({ label, id, user, error }) {
  return (
    <p style={{ margin: "6px 0" }}>
      <code style={{ display: "inline-block", minWidth: 160 }}>{label}</code>
      asked for <strong>{id}</strong> Β· showing{" "}
      <strong style={{ color: user && user.id !== id ? "crimson" : "#161" }}>
        {error ? "error: " + error : user ? user.name : "loading..."}
      </strong>
    </p>
  );
}

export default function App() {
  const [id, setId] = useState(1);

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6 }}>
      <Unguarded id={id} />
      <Guarded id={id} />
      <p>
        <button onClick={() => setId(1)}>Ask for user 1 (slow)</button>{" "}
        <button onClick={() => setId(2)}>Ask for user 2 (fast)</button>
      </p>
      <p style={{ color: "#666", fontSize: 13 }}>
        Click "user 1" then immediately "user 2". The unguarded row briefly
        shows User 2, then the slow response for user 1 lands and overwrites it
        in red. The guarded row aborts the first request and stays correct.
      </p>
    </div>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 41 of 119 in React.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All React.js questions
Up nextMedium

What is the Context API in React and when would you use it?

Continue