Explore Drafts
I thought my async code was solid – until it wasn’t

When I first started working with async/await in JavaScript, I thought I was doing everything right. Async/await gave me a synchronous look while performing asynchronous operations, and I thought that was all I needed to manage my code. However, as the complexity of my applications increased, I found myself running into some unexpected issues. My seemingly solid async code was starting to break, leading me to question if I was missing something.
In this post, I'll share my journey and how I discovered the generation token pattern, a simple and effective solution that drastically improved how I handle asynchronous operations in React. But before diving into that, let's first look at two widely used approaches to handle asynchronous tasks in React: useEffect
and AbortController
.
async/await
– looks synchronous, but it's still asynchronous
Async/await made my code look like it was running synchronously, but I realized it wasn't always the case. Under the hood, async/await still relies on Promises and the event loop, which can introduce unexpected issues like race conditions and redundant requests.
Consider the following example, where two asynchronous fetch calls are triggered one after the other:
async function fetchData() {
const response1 = await fetch('https://example.com');
const data1 = await response1.json();
const response2 = await fetch('https://example.com');
const data2 = await response2.json();
console.log('Data 1:', data1);
console.log('Data 2:', data2);
}
In the above case, both requests might resolve at different times, causing potential inconsistencies in the order of the data returned. But the problem doesn't end here. Async calls are generally not cancelled when they are no longer needed, which is a real issue in scenarios like type-ahead search where multiple requests can pile up.
useEffect
: Managing side effects in React
In React, useEffect
is the go-to hook for performing side effects such as fetching data, subscribing to events, or updating the DOM. It runs when a component mounts and can also be triggered when dependencies change. But when you're making asynchronous calls inside useEffect
, you often face issues of outdated responses or unnecessary re-renders.
Let's consider a search input that triggers an API request whenever the input changes:
import React, { useState, useEffect } from 'react';
const SearchComponent = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`/api/search?q=${query}`);
const data = await response.json();
setResults(data);
};
if (query) {
fetchData();
}
}, [query]);
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
/>
<ul>
{results.map((result, index) => (
<li key={index}>{result.name}</li>
))}
</ul>
</div>
);
};
While this works well in simple scenarios, race conditions can occur when the user types rapidly. Since the useEffect
hook runs on every change to the query
state, multiple requests will be sent in quick succession, and you may end up with outdated responses, like data from a previous search being rendered after a newer one.
This is where cancellation becomes important.
AbortController – Handling request cancellation
To tackle this problem, I looked into AbortController
. The AbortController
API allows you to cancel ongoing fetch requests. When a request is no longer needed, you can abort it to prevent unnecessary responses from being processed.
Here's how we could implement AbortController
in the above example:
import React, { useState, useEffect } from 'react';
const SearchComponent = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch(`/api/search?q=${query}`, { signal });
const data = await response.json();
setResults(data);
} catch (error) {
if (error.name === 'AbortError') {
console.log('Request was aborted');
} else {
console.error('Fetch error:', error);
}
} finally {
setLoading(false);
}
};
if (query) {
fetchData();
}
return () => {
controller.abort(); // Cleanup: abort previous request
};
}, [query]);
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
/>
{loading && <p>Loading...</p>}
<ul>
{results.map((result, index) => (
<li key={index}>{result.name}</li>
))}
</ul>
</div>
);
};
In this implementation, the AbortController
cancels the previous request whenever a new query is entered. This ensures that only the latest request is processed and that unnecessary or outdated responses are discarded.
While this solution works well, it still requires manual cleanup in useEffect
to ensure that the previous request is aborted.
Introducing the Generation Token Pattern – A simpler, more robust solution
While AbortController
works great, I wanted a more elegant solution that didn't require dealing with cleanup or cancelling requests manually. That's when I discovered the generation token pattern.
The generation token pattern is a simple, yet effective way to handle race conditions and outdated responses without the complexity of AbortController
or the manual cleanup of useEffect
. The idea is to assign a unique generation token to every request, and ignore older requests if they finish after the current one.
Here's the basic implementation:
let currentGen = 0;
async function fetchData() {
const gen = ++currentGen; // Increment generation token
try {
const response = await fetch('https://example.com');
const data = await response.json();
if (gen === currentGen) {
console.log('Received data:', data);
} else {
console.log('Old data, ignored');
}
} catch (error) {
console.error('Error fetching data:', error);
}
}
fetchData();
fetchData();
By simply using the generation token, we ensure that only the most recent request gets processed. This approach is simple and doesn't require manual cleanup, making it more maintainable in large applications.
Generation token in React: With and without useRef
Now that we have a solid pattern, let's integrate it into a React component and compare two different approaches: with and without useRef
.
Option 1: Using useRef
(preferred for performance)
useRef
allows us to persist values across renders without triggering re-renders, which is particularly useful for tracking the generation token in async operations.
import React, { useState, useRef } from 'react';
const SearchSuggestions = () => {
const [suggestions, setSuggestions] = useState([]);
const [loading, setLoading] = useState(false);
const currentGen = useRef(0); // Use ref to track generation token
const loadSuggestions = async (value) => {
const gen = ++currentGen.current; // Increment generation token
setLoading(true);
try {
const res = await fetch(`/api/search?q=${value}`);
const data = await res.json();
if (gen === currentGen.current) {
setSuggestions(data);
}
} catch (error) {
console.error('Error fetching suggestions:', error);
} finally {
setLoading(false);
}
};
return (
<div>
<input
type="text"
onChange={(e) => loadSuggestions(e.target.value)}
placeholder="Search..."
/>
{loading && <p>Loading...</p>}
<ul>
{suggestions.map((suggestion, idx) => (
<li key={idx}>{suggestion}</li>
))}
</ul>
</div>
);
};
Option 2: Without useRef
(using useState
)
Alternatively, you can use useState
to track the generation token. This method works well for simpler use cases, but it will trigger re-renders each time the token changes.
import React, { useState } from 'react';
const SearchSuggestions = () => {
const [suggestions, setSuggestions] = useState([]);
const [loading, setLoading] = useState(false);
const [currentGen, setCurrentGen] = useState(0); // Track generation token with state
const loadSuggestions = async (value) => {
const gen = currentGen + 1; // Generate new token
setCurrentGen(gen); // Update state with the new generation token
setLoading(true);
try {
const res = await fetch(`/api/search?q=${value}`);
const data = await res.json();
if (gen === currentGen) {
setSuggestions(data);
}
} catch (error) {
console.error('Error fetching suggestions:', error);
} finally {
setLoading(false);
}
};
return (
<div>
<input
type="text"
onChange={(e) => loadSuggestions(e.target.value)}
placeholder="Search..."
/>
{loading && <p>Loading...</p>}
<ul>
{suggestions.map((suggestion, idx) => (
<li key={idx}>{suggestion}</li>
))}
</ul>
</div>
);
};
When to use each approach
useRef
: For better performance when dealing with frequent async operations.useState
: Simpler to implement and better for simpler applications where performance isn’t a major concern.
Conclusion: Choosing the best approach
After experimenting with these solutions, I found the generation token pattern to be the most effective for handling race conditions and unnecessary API calls. It’s a cleaner, more maintainable solution, especially when integrated into React with useRef
.
If you’re working with cancellation scenarios, AbortController
is a good method to consider. However, for many cases, especially those involving search suggestions, the generation token pattern is simple and powerful.