QA012mediumCorrectness

A stale response can overwrite a newer one

What it costs you

This effect refetches whenever the user changes their selection, and does nothing to cancel the request already in flight. Pick one thing, then another before the first answers, and the first response lands last and wins: the selector says one thing and the panel shows another. Nothing errors, the data is real — it is just the wrong data, and it stays wrong until the next interaction.

The defect, and the fix

Both samples are scanned as app/panel/ScanPanel.tsx.

Our test suite runs both through the scanner on every build: the first must be reported, the second must not.

Reported
"use client";

export function ScanPanel() {
  const [projectId, setProjectId] = useState("latest");
  const [scan, setScan] = useState(null);

  useEffect(() => {
    fetch(`/api/scans/${projectId}`)
      .then((r) => r.json())
      .then((d) => setScan(d.scan));
  }, [projectId]);

  return (
    <select value={projectId} onChange={(e) => setProjectId(e.target.value)}>
      {/* … */}
    </select>
  );
}
Not reported
"use client";

export function ScanPanel() {
  const [projectId, setProjectId] = useState("latest");
  const [scan, setScan] = useState(null);

  useEffect(() => {
    let cancelled = false;
    fetch(`/api/scans/${projectId}`)
      .then((r) => r.json())
      .then((d) => {
        if (!cancelled) setScan(d.scan);
      });
    return () => {
      cancelled = true;
    };
  }, [projectId]);

  return (
    <select value={projectId} onChange={(e) => setProjectId(e.target.value)}>
      {/* … */}
    </select>
  );
}

What changed: The effect returns a cleanup that flips `cancelled`, so React disarms the in-flight request before re-running — a response for the previous selection can no longer land last and win.

How to fix it

Return a cleanup function from the effect that makes the late response a no-op — set a `cancelled` flag the `.then` checks, or abort the request with an AbortController and pass its `signal` to fetch. React calls the cleanup before it re-runs the effect, which is exactly when the older request becomes irrelevant.

Why this rule doesn't cry wolf

Each clause below exists because it was attacked: someone was asked to find correct code that the rule would flag, and the clause is what stopped it. This is published because a check you cannot audit is a check you have to take on faith.

Two clauses came from being wrong. The first draft asked whether the interpolated identifier was ASSIGNED INSIDE THE EFFECT, which an effect-local constant satisfies (`const base = process.env.NEXT_PUBLIC_API ?? ""`), so it fired on URLs that cannot vary between renders; what matters is that the identifier is DERIVED FROM A DEPENDENCY, since only a dependency can change mid-flight. The second: an identifier REFERENCED AGAIN AFTER the network call is the signature of code that already handles ordering — the keyed write `setCache(prev => ({ ...prev, [id]: data }))`, where out-of-order arrival is harmless, and the hand-rolled `if (latest.current === id) setScan(data)`, which is a cleanup function written by hand. Neither mentions cancelled/AbortController/signal, so nothing else excludes them. Also required: a non-empty dep array, a literal `fetch(`, a state setter after it, and the dependency must be a useState in this file wired to an onChange — the strict form, because relaxing it to "or any dep" reopens the child that fetches on a prop whose parent remounts it with a `key`, and that remount is invisible from the child's file. Frequency is this rule's weak point and is worth stating: two distinct defects, one codebase, one author. Shipped at medium for that reason.

This is not a security finding

QA012 is reported in its own section, separately from security findings. It does not change your security grade, and it does not fail your build unless you pass --fail-on-quality. The security catalogue lives at /rules.

Other Correctness checks

Check your own code

npx xploitscan scan .

Runs on every plan, including free. All 12 quality checks.