Skip to solution
mediumFrontend

How do you handle asynchronous operations in React functional components?

930 views
01

Understand 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.

asyncuseEffectdata fetchinghooks
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for React interviews — assumes useEffect and promises. Difficulty: Medium

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. The race-condition outcome in section 4 and React warning tex

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

The race condition, unguarded and then fixed with AbortController
Run Playground
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>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 41 of 119 decoded in the React.js track. One more won't hurt.

Back to track