NORMAL
← cd ~/blog
note #react#react-query#state-management#frontend#debugging

A control whose value feeds the query that supplies its own options will fight itself

Picking how many resources to book changed the job end time, the end time was part of the availability query key, the refetch marked some of the picked resources busy, and the prune that cleaned up then rewrote the count. Choose 4, land on 2.

A form let the user choose how many resources to book for a job. Pick 4, and the radio would jump to 2 on its own. Reported as a glitch; it was a loop.

The chain, in order:

  1. Picking a count sets resourceIds = pool.slice(0, count) and records that row’s projected finish time.
  2. The finish time is the job’s end time.
  3. The end time is part of the availability query key — the form asks “what is free between start and end?”
  4. So the pick triggers a refetch against a different window.
  5. Fresh availability comes back, and some of the resources just booked now read busy.
  6. A prune effect drops them from resourceIds and reports what survived.
  7. The prune handler was setCount(kept.length).

Step 7 is the bug, but steps 2–4 are why it was so confusing to reproduce: the selection is an input to the query that produces the selection’s own options. Any pick invalidates the data the pick was made from.

The fingerprint was in the UI the whole time — the row said 4 while the summary line underneath listed two resource names. Two views of state that should never disagree.

The fix is deciding which side is authoritative

The count is the user’s intent. A resource going busy is a reason to swap resources, not to quietly book fewer of them. So the prune now keeps the count and re-satisfies it from the current pool:

if (intended != null && pool.length >= intended) {
  const refill = pool.slice(0, intended);
  if (!sameIds(refill, kept)) { setIds(refill); return; }
}
setCount(kept.length || null);   // only when the pool genuinely cannot supply it

Termination matters here, because this runs inside the effect that triggered it. pool is already filtered to available items, so a refill slice is clean by construction and the next prune pass is a no-op — one extra round, not a cycle. The equality check stops a refill that would rewrite the field with what it already holds.

The general shape

If a control’s value is part of the query key that fetches that control’s options, you have a feedback loop and you must pick a winner:

  • Intent wins — keep the user’s choice, re-satisfy it from fresh data, and only override when it is genuinely unsatisfiable (and say so when you do).
  • Data wins — let the refetch reset the control, but then the control must visibly reset, not silently land somewhere else.

The failure mode is doing neither deliberately: some code path picks for you, and the user watches their choice change with no explanation. Silent correction of a deliberate action always reads as a bug, even when the new value is defensible.