Asynchronous operations create challenges in React because a component can change while a request is still running. A user may enter a new search term, navigate away from a page, or trigger multiple requests before the earlier ones complete.
Without careful handling, an old response can overwrite newer data or an unnecessary request can continue consuming resources.
Understand the stale response problem
Imagine a search component that sends a request whenever the search term changes. The request for “rea” might take longer than the request for “react”. If the earlier request completes last, the UI may display results for “rea” even though the current input is “react”.
This is a race condition between asynchronous operations.
Cancel obsolete requests
AbortController allows a fetch request to be cancelled:
import { useEffect, useState } from "react";
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function loadResults() {
try {
setError(null);
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error("Search failed.");
}
const data = await response.json();
setResults(data);
} catch (error) {
if (error.name !== "AbortError") {
setError(error);
}
}
}
loadResults();
return () => controller.abort();
}, [query]);
if (error) {
return <p>Unable to load results.</p>;
}
return (
<ul>
{results.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
This example assumes the endpoint returns an array of objects containing id and name. In production TypeScript code, the error and response data should also be typed and validated.
When the query changes or the component unmounts, the cleanup function aborts the previous request. The UI should also represent loading and empty states explicitly.
Do not confuse cancellation with server rollback
Aborting a browser request does not guarantee that the server has stopped processing it. For operations that modify data, the server may already have received and executed the request.
Cancellation is particularly useful for reads and searches. Mutations need additional safeguards, such as idempotency keys or application-specific concurrency controls, when duplicate execution would be harmful.
Use request identifiers when necessary
Sometimes cancellation is unavailable or insufficient. A request identifier can prevent an older response from updating state after a newer request has started.
The component records the latest request and applies a result only if it still belongs to the active request.
This pattern is useful for APIs that cannot be cancelled or for workflows involving several asynchronous steps.
Consider data-fetching libraries
Libraries such as TanStack Query can provide caching, request deduplication, loading states, invalidation, and other data-fetching features.
They do not remove the need to understand request lifecycles, but they can reduce the amount of custom synchronization code.
Handle loading and error states consistently
A robust async component should consider:
- Initial loading.
- Successful results.
- Empty results.
- Request failure.
- Retry behavior.
- Changes to the input while a request is running.
Avoid displaying stale results without making their relationship to the current input clear.
Conclusion
Reliable asynchronous React code accounts for changing inputs, request cancellation, and the order in which responses arrive. Use cleanup, cancellation, request identifiers, or an appropriate data-fetching library to ensure that the UI reflects the latest relevant data.