Skip to solution
hardFrontend

How do you safely handle async work and race conditions inside effects?

284 views
01

Understand the problem

Question presented to candidate: "Give me the full picture of doing async work inside an effect safely — every hazard you would guard against."

What a strong answer should cover:

  • Why cleanup ordering is the foundation: it runs before the next effect, so a flag set there is guaranteed to beat the previous run's continuation.
  • Race conditions: responses arriving out of order. A slow earlier request can overwrite a newer one, silently.
  • Two guards, ranked: AbortController (cancels the request and frees the connection) then a cancellation flag (only ignores the result).
  • Every await is a resumption point. The component may have unmounted or the dependency changed by the time each one resolves — so guard after each await, not only the first.
  • The effect callback must not be async — it would return a promise where React expects cleanup.
  • StrictMode runs setup, cleanup, setup, so an async effect must tolerate being started, aborted, and started again.
  • Stale closures interact with this: an async continuation reads values captured at its own render.
  • Swallow AbortError, and never leave the error state unhandled.
  • The honest recommendation: this is a lot of invariants to maintain by hand, which is the argument for a query library.

Clarifying questions expected:

  • "Is the work triggered by rendering, or by a user action?" — the latter belongs in a handler.
  • "Can the underlying API be aborted, or only ignored?"

Code / implementation expected: Yes — a multi-await sequence with a guard after each step.

reacteffectsasync
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 senior React interviews — assumes effects and promises. Difficulty: Hard

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. This is the deep hazard taxonomy; <a href="PASTE_ASYNC_OPS_URL_HERE" ta

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

A multi-step async effect with a guard after every await
Run Playground
import { useState, useEffect, useRef } from "react";

// Two dependent steps, both slow, so you can change the id mid-flight.
const wait = (ms, v) => new Promise((r) => setTimeout(() => r(v), ms));
async function getUser(id, signal) {
  await abortable(700, signal);
  return { id, name: "User " + id };
}
async function getOrders(userId, signal) {
  await abortable(700, signal);
  return [userId * 10, userId * 10 + 1];
}
function abortable(ms, signal) {
  return new Promise((resolve, reject) => {
    const t = setTimeout(resolve, ms);
    signal?.addEventListener("abort", () => {
      clearTimeout(t);
      const e = new Error("aborted"); e.name = "AbortError"; reject(e);
    });
  });
}

function Profile({ id, log }) {
  const [state, setState] = useState({ step: "idle", user: null, orders: null, error: null });

  useEffect(() => {
    const controller = new AbortController();
    setState({ step: "loading user", user: null, orders: null, error: null });
    log("start id=" + id);

    // The callback stays synchronous; the async work lives in here.
    (async () => {
      try {
        const user = await getUser(id, controller.signal);
        // CHECKPOINT 1 — the dependency may have changed during that await.
        if (controller.signal.aborted) return;
        setState((s) => ({ ...s, step: "loading orders", user }));

        const orders = await getOrders(user.id, controller.signal);
        // CHECKPOINT 2 — and again during this one.
        if (controller.signal.aborted) return;
        setState({ step: "done", user, orders, error: null });
        log("finished id=" + id);
      } catch (e) {
        if (e.name === "AbortError") { log("aborted id=" + id); return; }
        setState({ step: "error", user: null, orders: null, error: e.message });
      }
    })();

    // Cleanup runs BEFORE the next effect — this is the whole guarantee.
    return () => controller.abort();
  }, [id, log]);

  return (
    <div style={{ border: "1px solid #ddd", borderRadius: 8, padding: 12 }}>
      <p style={{ margin: 0 }}>
        asked for <strong>{id}</strong> · step: <strong>{state.step}</strong>
      </p>
      {state.user && (
        <p style={{ margin: "4px 0", color: state.user.id !== id ? "crimson" : "#161" }}>
          {state.user.name}{state.orders ? " · orders " + state.orders.join(", ") : ""}
        </p>
      )}
      {state.error && <p style={{ color: "crimson" }}>{state.error}</p>}
    </div>
  );
}

export default function App() {
  const [id, setId] = useState(1);
  const [log, setLog] = useState([]);
  const push = useRef((line) => setLog((l) => [...l, line])).current;

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6 }}>
      <Profile id={id} log={push} />
      <p>
        {[1, 2, 3].map((n) => (
          <button key={n} onClick={() => setId(n)} style={{ marginRight: 6 }}>user {n}</button>
        ))}
        <button onClick={() => setLog([])}>clear log</button>
      </p>
      <pre style={{ background: "#f6f6f6", padding: 10, borderRadius: 6, fontSize: 12, maxHeight: 150, overflow: "auto" }}>
        {log.length ? log.join("\n") : "(nothing yet)"}
      </pre>
      <p style={{ color: "#666", fontSize: 13 }}>
        Click user 1, then user 2 while it is still loading. The log shows the
        first run aborted rather than finishing — the checkpoints stop it
        writing state for a user you have already navigated away from.
      </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 110 of 119 decoded in the React.js track. One more won't hurt.

Back to track