React 搜尋、自動完成、級聯選單與明細切換常有同一種難查缺陷:使用者先發出的慢請求晚於後發出的快請求完成,舊回應最後寫入 state,畫面因此倒退。只加防抖不能保證回應順序,只呼叫 abort() 也不能保證所有非網路工作都停止。可靠做法是把「取消過期工作」與「只允許最新世代提交」分成兩層,再讓 loading、錯誤與本地輸入共用同一個世代判斷。本文以 React 官方 Effect cleanup、DOM AbortController 規格與可重現 Node 沙箱,建立一套可移植的競態驗證方法。
React 請求競態的核心問題是什麼
直接答案:問題不在請求同時存在,而在任何完成的回應都能無條件提交畫面。只要兩次操作的完成順序與發起順序不同,舊結果就可能覆蓋新意圖,因此提交 state 前必須驗證回應仍屬於目前世代。
典型情境是使用者快速輸入兩次查詢。第一次查詢資料較多,需要較久;第二次較精準,很快回來。第二次先把畫面更新成正確結果,第一次稍後完成,又呼叫一次 setResults,最後畫面反而顯示舊條件。這不是 React 特有的排程錯誤,而是非同步完成順序沒有被納入狀態契約。
同樣問題也會出現在級聯選單。使用者先選 A,程式開始載入 A 的子項目;隨即改選 B,B 的資料先回來。若 A 的回應最後仍可寫入下拉選項,畫面會顯示 A,但表單其他欄位可能已經記錄 B。此時畫面與送出的值分岔,比單純顯示錯資料更危險。
React 官方 useEffect 文件用 cleanup 中的旗標忽略過期回應,原因正是網路回應可能不按送出順序返回。這個原則不只適用 Effect。任何 async event handler、autocomplete callback、modal 載入、分頁切換或按鈕動作,只要多次觸發可以交錯,就需要定義「誰有權提交結果」。
判斷是否存在競態,不應只在正常網路速度下手動點擊。應刻意建立「舊請求慢、新請求快」的對抗情境,再觀察最後畫面、loading、錯誤訊息與送出值是否一致。沒有這個逆序測試,程式在大多數操作中都可能看起來正常。
AbortController 與世代序號各自解決什麼
直接答案:AbortController 負責通知可取消的工作停止,世代序號負責拒絕已過期的提交。前者節省資源,後者保護狀態正確性;兩者功能不同,應同時使用,而不是把取消成功當成最新性證明。
AbortController 會提供一個 signal,呼叫 abort() 後,支援該訊號的 API 可以停止或拒絕工作。對 fetch 而言,新的搜尋發起前取消舊搜尋,可減少不必要的網路與解析工作。WHATWG DOM Standard 定義了 controller、signal 與 abort reason 的基本語意;Node.js 也提供相容的全域物件,適合在隔離沙箱測試。
但取消不是完整正確性邊界。第一,底層函式可能完全不接受 signal,例如自行包裝的 Promise、CPU 計算、舊版 client 或已經完成傳輸但正在解析的工作。第二,取消與完成本身也可能接近同時發生;如果結果已經進入後續 callback,單靠呼叫 abort() 不足以證明 callback 不會執行。第三,有些操作不是「新請求取代舊請求」,而是使用者直接打字或清空欄位,這時可能沒有另一個 fetch 可供取消,仍要讓先前回應失效。
世代序號提供第二層保護。元件以 useRef(0) 保存目前世代;每次開始新操作先遞增,並把當下值保存為 myGeneration。非同步工作完成後,只有 myGeneration === generationRef.current 才能寫入結果、錯誤或 loading。任何後續操作只要再遞增序號,舊 callback 就自然失去提交資格。
這兩層的順序要明確:先增加世代撤銷舊提交權,再取消舊 controller、建立新 controller 並啟動工作。完成時先比對世代,最後只有目前世代可以關閉 loading。如此即使舊工作忽略取消,它也只能完成,不能改變可見狀態。
在 React handler 中建立可審查的最新請求契約
直接答案:把 controller、世代與 loading 的所有權放在同一個 hook 或 handler 邊界。每次互動都先淘汰上一代,成功、失敗與 finally 都檢查世代,輸入變更也要能主動作廢尚未完成的遠端選取。
以下是元件內的整合片段,不是可單獨執行的完整元件;需先匯入 useRef、useEffect,並定義對應 state setters,其中 error 型別為 string | null。它適合搜尋、autocomplete 與級聯選單;API 路徑是示意契約,應依專案替換:
const requestGeneration = useRef(0);
const activeController = useRef(null);
async function loadOptions(query: string) {
const mine = ++requestGeneration.current;
activeController.current?.abort();
const controller = new AbortController();
activeController.current = controller;
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/options?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (mine !== requestGeneration.current) return;
if (!Array.isArray(data)) throw new Error("Invalid response shape");
setOptions(data);
} catch (error) {
if (controller.signal.aborted) return;
if (mine !== requestGeneration.current) return;
setError("讀取失敗,請重試");
} finally {
if (mine === requestGeneration.current) setLoading(false);
}
}
function handleInputChange(value: string) {
++requestGeneration.current;
activeController.current?.abort();
activeController.current = null;
setLoading(false);
setError(null);
setOptions([]);
setInputValue(value);
}
useEffect(() => () => {
++requestGeneration.current;
activeController.current?.abort();
}, []);
catch 不能把所有錯誤都當成取消。只有 signal 已中止或錯誤明確屬於 abort 時才安靜忽略;真正的 HTTP、格式或程式錯誤仍應在目前世代顯示。反過來,過期世代即使收到真錯誤,也不應把新畫面改成失敗,因為使用者已經進入另一個操作。
本地輸入不會自動發起下一個請求,所以 handleInputChange 在撤銷上一代後,必須自行清除 loading、error 與過期選項;不能等待已失去所有權的舊 finally 清理。下一次搜尋開始時也應清除前次錯誤。此片段只驗證頂層陣列形狀,實際選項欄位仍需另作驗證。
finally 是最常被忽略的競態點。若每個請求完成都無條件 setLoading(false),舊請求可能在新請求仍執行時提前關閉遮罩;若無條件設成 true,則會永久卡住。loading 應視為「目前世代是否仍有工作」,不是所有 Promise 的共同布林值。只有擁有目前世代的 callback 可以解除它。
對級聯選單,僅在選取事件增加世代還不夠。使用者在等待遠端子項目時手動輸入、清空或切換上層欄位,也代表舊結果不再符合意圖,因此這些本地事件第一行就要增加世代。世代不是請求計數,而是使用者意圖版本。
如果使用框架或成熟資料層已提供快取、請求去重與最新性處理,優先使用其正式機制。React 官方也提醒,直接在 Effect 抓資料會帶來快取、伺服器渲染與 waterfall 等額外問題。本文模式的價值,在於自建 handler、舊專案或無資料層抽象時,仍能明確定義狀態所有權。
loading、錯誤與資料必須共享同一世代
直接答案:只保護資料寫入仍不夠。過期回應若能改錯誤訊息、清除 loading、關閉 modal 或重設選項,畫面仍會分岔。每一個由非同步工作衍生的狀態,都必須接受相同世代檢查。
常見修補只在 setResults 前加判斷,卻讓 catch 與 finally 無條件執行。結果是資料看似正確,但舊請求失敗後跳出錯誤;或新請求仍在跑,舊請求先關掉 loading。這些訊號會誤導使用者,也可能允許尚未載入完成的表單提早送出。
較完整的狀態模型至少包含四項:目前輸入、已套用查詢、結果資料、請求狀態。輸入可即時變動;已套用查詢只在確定發起搜尋時保存;結果只能由對應世代提交;loading 與 error 也只能由同一世代改變。若無限捲動還有分頁游標,第一頁成功後應凍結已套用條件,後續頁沿用同一查詢版本,避免中途輸入變更污染資料集合。
錯誤訊息也要區分取消、失敗與空結果。取消是控制流程,不應顯示「讀取失敗」;HTTP 非成功或解析錯誤是真失敗,必須可見;成功但陣列為空才是沒有資料。把三者都渲染成空表格,會讓使用者與維運人員無法判斷是條件無結果、權限問題還是網路錯誤。
元件卸載時同樣要取消目前 controller 並讓世代失效。Effect 可以在 cleanup 中處理;事件型 hook 則可提供 unmount cleanup。目的不是避免每一個 setState 警告,而是確保已離開的頁面不能再提交業務狀態或全域通知。
驗證與重現:固定延遲證明舊回應不能覆蓋新意圖
直接答案:用固定延遲建立「舊慢新快」案例,先證明天真的流程必定失敗,再分別驗證世代閘門、取消訊號與本地輸入作廢。通過條件應檢查最終資料、loading 與取消次數,而不是只看程式沒有拋錯。
本次在排程環境實際使用 Node 26.7.0 執行具名沙箱。沙箱不連外、不使用真實 API、不建立帳號,只以固定的 60ms 舊工作與 10ms 新工作模擬逆序完成。測試先刻意重現錯誤,再驗證修法,避免「測試只跑成功路徑」造成假信心。
- 確認實際 runtime,執行具名沙箱;
node --version只觀察版本,不會鎖定或安裝版本:
node --version
node verification/react_request_race_20261010/request_race_lab.js
檢查天真流程。舊工作延遲 60ms,新工作延遲 10ms;兩者都無條件寫入時,最後可見值必須是錯誤的
old。若未先證明缺陷可重現,後續修法沒有有效基準。檢查只有世代閘門的流程。兩個工作都完成,但舊世代不可提交;最後可見值必須是
new,而且 loading 必須為false。這證明即使底層不可取消,狀態仍受保護。檢查 AbortController 加世代序號。第二次操作要中止第一次,沙箱應觀察一次 abort;最後值仍為
new,只有最新世代能解除 loading。若 abort 次數或最終狀態不符,程式以非零碼結束。檢查本地輸入作廢。遠端選取尚未完成時模擬使用者打字,世代先遞增;遠端回應完成後不得覆蓋
typed-locally。這一步驗證「沒有新 fetch 也能使舊工作失效」。
實際執行結果為 SUMMARY 4/4 checks passed on Node 26.7.0。四項依序證明:無保護流程會被舊回應覆蓋、世代閘門保留最新結果、取消加世代可停止過期工作並維持 loading、以及本地輸入可以作廢遠端選取。這些結果驗證的是非同步控制模式;實際 React 專案仍應以自己的 HTTP client、元件卸載、Strict Mode、錯誤 UI 與測試框架重跑。
完整可重現檔案與校稿回歸
在自己的空白測試目錄建立 verification/react_request_race_20261010,將下列兩個完整檔案依檔名存入;不需要存取本站內部工作目錄。第二個檔案是離線狀態模型,不是 React 元件測試。它補驗本地輸入解除 loading、過期錯誤、錯誤重試、回應形狀與卸載後禁止寫入。兩個檔案均已由 Editor 實際重跑。
request_race_lab.js
"use strict";
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function fakeRequest(label, milliseconds, signal) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => resolve(label), milliseconds);
if (!signal) return;
const rejectAbort = () => {
clearTimeout(timer);
reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
};
if (signal.aborted) {
rejectAbort();
return;
}
signal.addEventListener("abort", rejectAbort, { once: true });
});
}
async function verifyNaiveRace() {
let visible = "idle";
const slowOld = fakeRequest("old", 60).then((value) => {
visible = value;
});
const fastNew = fakeRequest("new", 10).then((value) => {
visible = value;
});
await Promise.all([slowOld, fastNew]);
if (visible !== "old") {
throw new Error(`Naive race was not reproduced; visible=${visible}`);
}
console.log("PASS naive flow allows the slow old response to overwrite the new result");
}
async function verifyGenerationGate() {
let generation = 0;
let visible = "idle";
let loading = false;
async function search(label, milliseconds) {
const mine = ++generation;
loading = true;
try {
const value = await fakeRequest(label, milliseconds);
if (mine !== generation) return;
visible = value;
} finally {
if (mine === generation) loading = false;
}
}
const slowOld = search("old", 60);
await delay(5);
const fastNew = search("new", 10);
await Promise.all([slowOld, fastNew]);
if (visible !== "new" || loading !== false) {
throw new Error(`Generation gate failed; visible=${visible}, loading=${loading}`);
}
console.log("PASS generation gate keeps the newest result and releases loading once");
}
async function verifyAbortAndGeneration() {
let generation = 0;
let currentController = null;
let visible = "idle";
let loading = false;
let abortCount = 0;
async function search(label, milliseconds) {
currentController?.abort(new DOMException("Superseded", "AbortError"));
const controller = new AbortController();
currentController = controller;
const mine = ++generation;
loading = true;
try {
const value = await fakeRequest(label, milliseconds, controller.signal);
if (mine !== generation) return;
visible = value;
} catch (error) {
if (error?.name === "AbortError") {
abortCount += 1;
return;
}
throw error;
} finally {
if (mine === generation) loading = false;
}
}
const slowOld = search("old", 60);
await delay(5);
const fastNew = search("new", 10);
await Promise.all([slowOld, fastNew]);
if (visible !== "new" || loading !== false || abortCount !== 1) {
throw new Error(
`Abort plus generation failed; visible=${visible}, loading=${loading}, abortCount=${abortCount}`,
);
}
console.log("PASS abort plus generation cancels obsolete work and preserves the newest state");
}
async function verifyInputInvalidation() {
let generation = 0;
let visible = "typed-locally";
async function chooseRemote(label, milliseconds) {
const mine = ++generation;
const value = await fakeRequest(label, milliseconds);
if (mine === generation) visible = value;
}
const pendingSelection = chooseRemote("remote-selection", 30);
generation += 1;
visible = "typed-locally";
await pendingSelection;
if (visible !== "typed-locally") {
throw new Error(`Input invalidation failed; visible=${visible}`);
}
console.log("PASS local input invalidates an older asynchronous selection response");
}
async function main() {
await verifyNaiveRace();
await verifyGenerationGate();
await verifyAbortAndGeneration();
await verifyInputInvalidation();
console.log(`SUMMARY 4/4 checks passed on Node ${process.versions.node}`);
}
main().catch((error) => {
console.error("FAIL", error);
process.exitCode = 1;
});
editor_state_contract.js
"use strict";
// Offline model of the article's handlers, not a React renderer.
const assert = require("node:assert/strict");
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function model() {
let generation = 0;
let controller = null;
const state = { loading: false, error: null, options: [], input: "" };
async function load(work) {
const mine = ++generation;
controller?.abort();
const own = new AbortController();
controller = own;
state.loading = true;
state.error = null;
try {
const data = await work;
if (mine !== generation) return;
if (!Array.isArray(data)) throw new Error("Invalid response shape");
state.options = data;
} catch (error) {
if (own.signal.aborted || mine !== generation) return;
state.error = "Unable to load options. Try again.";
} finally {
if (mine === generation) state.loading = false;
}
}
function edit(value) {
++generation;
controller?.abort();
controller = null;
state.loading = false;
state.error = null;
state.options = [];
state.input = value;
}
function unmount() {
++generation;
controller?.abort();
}
return { state, load, edit, unmount };
}
async function main() {
// Old rejection cannot dismiss a new spinner or expose an error.
let m = model();
let old = deferred(), current = deferred();
let a = m.load(old.promise), b = m.load(current.promise);
old.reject(new Error("obsolete failure"));
await a;
assert.equal(m.state.loading, true);
assert.equal(m.state.error, null);
current.resolve(["new"]);
await b;
assert.deepEqual(m.state.options, ["new"]);
assert.equal(m.state.loading, false);
console.log("PASS stale failure cannot mutate current status");
m = model();
old = deferred();
a = m.load(old.promise);
m.edit("typed-locally");
assert.equal(m.state.loading, false);
old.resolve(["obsolete"]);
await a;
assert.deepEqual(m.state, {
loading: false, error: null, options: [], input: "typed-locally"
});
console.log("PASS local edit releases loading and rejects stale data");
m = model();
await m.load(Promise.resolve({ wrong: true }));
assert.notEqual(m.state.error, null);
assert.equal(m.state.loading, false);
current = deferred();
b = m.load(current.promise);
assert.equal(m.state.error, null);
current.resolve([]);
await b;
assert.deepEqual(m.state.options, []);
assert.equal(m.state.error, null);
console.log("PASS invalid shape is failure and retry clears error");
m = model();
old = deferred();
a = m.load(old.promise);
m.unmount();
const snapshot = JSON.stringify(m.state);
old.resolve(["late"]);
await a;
assert.equal(JSON.stringify(m.state), snapshot);
console.log("PASS unmount revokes all pending state writes");
console.log(`SUMMARY editorial model 4/4 passed on Node ${process.versions.node}`);
}
main().catch(error => { console.error(error); process.exitCode = 1; });
node verification/react_request_race_20261010/request_race_lab.js
node verification/react_request_race_20261010/editor_state_contract.js
PASS naive flow allows the slow old response to overwrite the new result
PASS generation gate keeps the newest result and releases loading once
PASS abort plus generation cancels obsolete work and preserves the newest state
PASS local input invalidates an older asynchronous selection response
SUMMARY 4/4 checks passed on Node 26.7.0
PASS stale failure cannot mutate current status
PASS local edit releases loading and rejects stale data
PASS invalid shape is failure and retry clears error
PASS unmount revokes all pending state writes
SUMMARY editorial model 4/4 passed on Node 26.7.0
通過條件:兩個命令皆以 0 結束,並輸出各自的 4/4 摘要;assertion 失敗會以非零碼結束。第一套測試刻意觀察到未防護缺陷才算基準通過,不代表測試程序應失敗。Node 26.7.0 是本次觀察到的版本,不是專案版本鎖定。這些是合成模型測試,未驗證 React 掛載、渲染或真實 fetch。
程式碼審查與發布前清單
直接答案:審查時不要只找 AbortController,而要追蹤每一個非同步 callback 有權修改哪些 state。發布前以逆序回應、取消、錯誤、清空輸入與卸載五種情境,確認最後畫面始終代表最新使用者意圖。
可將下列項目加入 pull request 與回歸測試:
- 新操作開始時,是否明確淘汰上一個 controller 與世代?
- 成功回應寫入資料前,是否確認自己仍是目前世代?
catch是否區分取消、過期錯誤與目前世代的真錯誤?finally是否只有目前世代能關閉 loading?- 本地輸入、清空、返回上層或切換頁籤,是否也會讓舊回應失效?
- 元件卸載後,是否取消工作並阻止全域狀態或通知被過期 callback 修改?
- 是否刻意測過舊請求比新請求晚完成,而不是只在正常速度手動操作?
- 空結果、讀取失敗與取消,是否有不同且可觀察的 UI 狀態?
- 分頁與無限捲動是否固定使用已套用查詢,而不是讀取尚未送出的輸入?
- 若資料框架已處理取消、快取與競態,是否避免重複建立互相衝突的控制層?
可追蹤的工程指標包括:過期回應被抑制次數、取消次數、查詢失敗率、loading 持續時間、同一元件同時進行的請求數,以及「畫面條件與實際送出條件不一致」的回歸案例數。這些指標不是效能承諾,而是幫助團隊判斷競態是否頻繁發生、控制邏輯是否誤殺有效請求。
最終原則是:取消處理資源,世代保護正確性,狀態所有權必須一致。 當資料、loading、錯誤與本地輸入都遵守同一個最新意圖契約,即使請求逆序、底層不支援取消或使用者快速切換,過期工作也不能再把畫面拉回過去。