Fix stale state in asynchronous React handlers

A React counter has an asynchronous Increment button. Clicking it twice quickly increases the count only once. Another screen saves a draft, then a delayed callback announces the wrong title. Both failures can involve captured state, but they do not necessarily require the same correction.
React handlers see the state values from the render that created them. Asynchronous work can complete after newer renders exist. This article uses an illustrative counter and draft-save flow to explain when a functional update is the smallest fix and when request identity or explicit input capture is needed instead.
Reproduce the lost update precisely
Start with count zero, click Increment twice before either delayed operation completes, then let both finish. If both handlers captured zero and both call a replacement update to one, the final state is one rather than two.
This is not evidence that React forgot an update. The code asked for the same replacement twice. The important distinction is between replacing state with a captured value and expressing an update relative to the state React supplies while processing the queue.
Use a controlled promise or test double rather than random delays when testing. Complete both operations in a deliberate order and assert the final count. A test that waits after the first click does not exercise overlapping handlers and can miss the defect entirely.
Use a functional update for prior-state transformations
If the intended result depends on the previous value, express that relationship in the updater function. The illustrative handler below assumes doWork is a separately defined asynchronous operation and that each successful operation should increment once.
async function handleIncrement() {
await doWork();
setCount(current => current + 1);
}
The updater receives the state value relevant to that queued update rather than relying on the handler's captured count. Keep updater functions pure: do not send requests, write analytics, or modify external objects inside them.
This pattern applies to adding an item, toggling from the previous boolean, or adjusting a counter. It does not automatically solve every asynchronous problem. If the response belongs to an obsolete search, applying it through a functional update can still commit the wrong data.
Capture request inputs intentionally
For a draft save, decide which draft is being saved: the value at click time or whatever exists when the network call starts later. Usually the request should capture the submitted input explicitly so the user can continue editing without changing an in-flight operation.
Store the submitted draft in a local constant or construct the request payload synchronously. Use that captured value when explaining the operation's result. A message such as “Saved title A” should describe the submitted title, not imply that a later title B was also saved.
This is a deliberate snapshot, not a stale-state bug. The mistake is confusing the submitted snapshot with the current editable state. Make those roles explicit in names and UI: submitted revision, current draft, and last saved revision.
Use latest values only when the contract requires them
Some callbacks genuinely need the latest state, such as a long-lived subscription that reacts to current preferences. In those cases, restructure the subscription or use an appropriate current-value mechanism supported by the project's React version and patterns.
A ref can hold a current value without causing a render, but it should not replace state that drives the UI. Keep synchronization deliberate and explain why the callback needs latest data. A blanket ref-based workaround can make state flow harder to follow.
Avoid disabling dependency checks merely to keep a callback stable. That can preserve the wrong captured values indefinitely. The guide on effect loops explains how to address dependency structure without hiding reactive inputs.
Protect async ownership separately
A save response might complete after the user switches to another document. The captured payload is correct, yet updating the current document's status would be wrong. Use document identity and request ownership to decide whether the completion can update the visible state.
Apply ownership to errors and pending state too. An old save failure should not replace a newer success. A completed request for document A should not mark document B as saved. These are lifecycle rules beyond the prior-state updater.
If the server returns a revised record, merge it according to the contract. Blindly replacing the editable draft can erase changes made during the request. Consider a saved revision marker or explicit conflict handling rather than treating a successful response as permission to overwrite current edits.
Use AI to distinguish the cases
Give a coding assistant the handler, state declarations, and intended timing. Ask it to identify each captured value and whether it should be a submitted snapshot, a prior-state transformation, or a current-value read.
An illustrative prompt is: “Two increments overlap, and a draft save completes after further editing. Explain which values each handler captures. Propose a functional update only where prior state is required. Do not overwrite unsaved draft changes.” This avoids the generic advice to add useCallback everywhere.
Review the proposed fix against the semantics. useCallback controls function identity; it does not magically make captured state current. Adding dependencies can recreate the function with new values, but already-running handlers still retain their own captured inputs.
Verify overlap and user intent
Test two successful increments, one success and one failure, and completions in reversed order. Test saving a draft while editing it, switching documents during a save, and retrying a failed save. Assert both visible state and the request payloads.
Check that error recovery preserves user input. A failure should not revert the draft to the submitted snapshot unless the product explicitly intends that behavior. A success should not imply that later edits are saved.
The useful conclusion is not that closures are bad. Captured values are often exactly what a request needs. Correct asynchronous state management names the role of each value, uses functional updates for prior-state transformations, and keeps obsolete operations from owning the current screen.
When several state fields must change together, consider whether they represent one transition. Updating a pending flag, saved revision, and error separately can be correct, but a reducer or coherent state object may make impossible combinations easier to avoid. Choose that structure for the actual lifecycle rather than introducing a global state library. The essential requirement remains the same: each completed operation updates the state that belongs to its own input and authority.
Sources
React: State as a Snapshot explains render-time state values.
React: Queueing a Series of State Updates documents updater functions.
MDN: AbortController documents cancellation for related request lifecycles.
About the Author

Kamruz
Full Stack Developer
A passionate developer who loves creating modern web applications and sharing knowledge with the community.
More About Me