This article is published in English.
Six Async Timing Mistakes That Surface as React Rendering Bugs
Learn to spot six async JavaScript patterns, from stale captured values to missing Promise returns, that make React components show wrong or impossible state.
Many issues that get filed as "React bugs" never originated in React. A list that shows results for an older query, a spinner that never goes away, a success toast that fires before the data is ready: React is simply where these problems become visible. The real cause usually sits one layer lower, in how asynchronous JavaScript moves values through time.
Async code introduces time into a program, and every await or .then() is a point where the world can change underneath you. Below are six recurring timing mistakes, why each produces a confusing UI symptom, and the small change that fixes it, so you can catch them in code review.
Mistake 1: Obsolete requests still write to current state
A search box is the textbook case. The effect below already debounces input by 300 ms and clears the timer on cleanup.
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const timeoutId = setTimeout(async () => {
const results = await search(query);
setResults(results);
}, 300);
return () => {
clearTimeout(timeoutId);
};
}, [query]);
Now picture someone typing "Async JavaScript Mistakes". They type "Async", pause just long enough for the debounce to expire, and a request goes out. Then they keep typing, and a second request fires for the full phrase. Debouncing reduced the number of requests, but it did not prevent two of them from being in flight at once.
The order in which requests leave the browser is under your control. The order in which responses come back is not. If the longer query resolves first and the "Async" query resolves second, the second setResults call wins, and the list shows results for "Async" while the input clearly reads "Async JavaScript Mistakes".
Nothing misbehaved here. Networks are allowed to reorder completions, and the code never told React which response was still relevant. The usual remedy is to cancel superseded work with an AbortController created inside the effect and aborted in its cleanup, so a stale response either never arrives or is ignored. If the data flow is already built on RxJS, switchMap gives you the same "only the latest wins" semantics. For a deeper treatment of this exact scenario, see fixing race conditions that debouncing cannot solve.
Mistake 2: Deciding with values captured before an await
Treat every await as a boundary. Whatever you knew before it is a snapshot; whatever happens after it runs in a later moment, possibly after other state has changed.
const handlePublish = async () => {
const { canPublish } = permissions;
await saveDraft();
if (canPublish) {
publish();
}
};
This handler reads canPublish from permissions, waits for the draft to save, and then decides whether to publish. The problem is that the decision is based on a value that was true at the start. If the user's permissions were revoked while saveDraft() was running, the local constant still says yes.
The same shape shows up with selected items, active filters, route parameters, editor contents and many other pieces of state. When a decision after an await depends on current application state, re-read that state after the boundary (from a ref, a store, or a fresh request) instead of trusting the earlier copy.
Mistake 3: Assuming the rest of the function always runs
Here is a loading flag wrapped around a fetch.
setLoading(true);
const dashboard = await getDashboard();
setDashboard(dashboard);
setLoading(false);
If getDashboard() rejects, execution jumps out of the function at the await, and setLoading(false) never runs. The spinner stays on screen indefinitely.
When some piece of state models the lifetime of an async operation, its reset must happen on both the success and the failure path. A finally block states that intent directly:
setLoading(true);
try {
const dashboard = await getDashboard();
setDashboard(dashboard);
} finally {
setLoading(false);
}
Notice that the try block still has no catch. The error continues to propagate to whoever called this code, which is often what you want, while the loading flag is guaranteed to clear. Add a catch only if this is the right place to turn the failure into UI, such as an error message.
Mistake 4: Independent requests producing incoherent screen states
Firing unrelated requests in parallel is often perfectly reasonable.
getProfile().then(setProfile);
getPermissions().then(setPermissions);
getPreferences().then(setPreferences);
Each response lands in its own piece of state whenever it happens to arrive. That means React can render any combination along the way: a profile with no permissions or preferences, permissions and preferences without a profile, and so on through every ordering the network produces.
Some of those combinations may be meaningless or even dangerous for your screen. When several responses together describe one coherent screen state, the requests can stay parallel while a single owner assembles the result, for example by awaiting Promise.all and committing everything in one state update, or by modelling the screen as a reducer with explicit loading, ready and error states. Parallel fetching and independent UI state are two separate decisions.
Mistake 5: Building the next state from a stale snapshot
This one hides easily in event handlers.
const handleAdd = async () => {
await saveItem(newItem);
setItems([...items, newItem]);
};
items is whatever the component held when handleAdd began. While saveItem() was pending, another action might have added or removed entries. When the handler resumes, it spreads the old array and overwrites those newer changes.
Whenever the next value depends on the previous one, let React supply the previous value through the updater form:
setItems(current => [...current, newItem]);
The updater runs against the latest committed state at the moment the update is processed, so concurrent changes are preserved. Stale closures are not only an effect problem: any async callback can hold on to values longer than you expect.
Mistake 6: Breaking a Promise chain by not returning
This chain looks strictly sequential: save, then refresh widgets, then mark as saved.
saveDashboard()
.then(() => refreshWidgets())
.then(() => setSaved(true));
Now look at how refreshWidgets might be written:
const refreshWidgets = () => {
getWidgets().then(setWidgets);
};
The function starts a request but returns undefined, not the Promise. From the outer chain's point of view, refreshWidgets() finished instantly, so the next .then runs right away and setSaved(true) can fire while widgets are still loading.
The fix is a single keyword, returning the Promise so the chain can wait on it:
const refreshWidgets = () => {
return getWidgets().then(setWidgets);
};
An async function achieves the same thing implicitly, because it always returns a Promise that settles when its body completes:
const refreshWidgets = async () => {
const widgets = await getWidgets();
setWidgets(widgets);
};
In the UI, this bug can look like a success message appearing too early, stale data lingering on screen, or a redirect that happens before the refresh has landed. None of those point obviously at a missing return. TypeScript lint rules such as @typescript-eslint/no-floating-promises can catch many of these cases automatically.
Key takeaways
The line between React and JavaScript is not always obvious. React renders state, but asynchronous JavaScript decides when that state arrives and whether it is still fresh, obsolete or inconsistent by the time it does. When reviewing async code in components, three questions surface most of these bugs:
- When was this value captured, and could it have changed across an
await? - Which async operation owns this state update, and can an older operation overwrite a newer one?
- Is the operation still relevant when it completes, and does every path, including failure, leave the UI in a valid state?