Skip to the solution
HardFrontend6 min read

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

284 views

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.

  • react
  • effects
  • async

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 Β· 11 sections

Try it in the playground

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

ReactA multi-step async effect with a guard after every await
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>
  );
}

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 110 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 nextHard

How do you keep the UI responsive during an expensive update without a Suspense fallback flash?

Continue