Be Aware of the `async` Keyword When Caching Promises
While experimenting with React's experimental use() hook, particularly for data loading, I encountered an intriguing challenge. My approach involved creating a fetch-wrapper with caching logic to efficiently utilize the use() hook. However, this seemingly simple solution led to an unexpected issue.
During component re-renders, I observed a brief but recurring activation of the suspense boundary, which manifested as a fleeting loading screen. Initially, I suspected this might be due to bugs in React's Suspense + use() implementation. But upon closer examination, it became evident that the root cause was a flaw in my caching logic, not in React itself. This mistake resulted in each promise being treated as new by the suspense system.
This experience highlighted the importance of a deep understanding of async functions and promises in JavaScript, especially when working with frameworks like React. Such insights are crucial for preventing unintended behaviors in applications. Below is a detailed exploration of this issue and the steps to resolve it.
What do you think will be the output of the following code?
const cache = new Map();
async function cachedFetch(url) {
if (!cache.has(url)) {
cache.set(url, fetch(url));
}
return cache.get(url);
}
console.log(cachedFetch("/test") === cachedFetch("/test"));
At first glance, you might think the output is true
. However, the actual answer is false
.
Debugging the Code: Unveiling the Truth
If we add some logging to the cachedFetch
function:
const cache = new Map();
async function cachedFetch(url) {
if (!cache.has(url)) {
console.log("cache miss");
cache.set(url, fetch(url));
} else {
console.log("cache hit");
}
return cache.get(url);
}
console.log(cachedFetch("/test") === cachedFetch("/test"));
The console output reveals, that underlying fetched data is correctly cached:
cache miss
cache hit
false
This behavior occurs because the cachedFetch
function, marked with the async
keyword, always returns a new promise. Consequently, consecutive calls to cachedFetch
result in different promise objects.
The Implications: Unexpected Behavior in Your Code
Such behavior can lead to unforeseen issues. For example, if you put the result of such a cached call in the dependency array of React's useEffect()
, the effect would trigger on every render, not just when the promise changes, leading to potential performance issues and bugs.
The Solution: Refactoring the Code
To resolve this, you can modify the cachedFetch
function by removing the async
keyword:
function cachedFetch(url) {
if (!cache.has(url)) {
cache.set(url, fetch(url));
}
return cache.get(url);
}
console.log(cachedFetch("/test") === cachedFetch("/test"));
Now, cachedFetch("/test") === cachedFetch("/test")
returns true
. The function consistently returns the same promise for repeated calls with the same argument, ensuring the expected caching behavior.
Best Practices: Avoid Pitfalls with Async Functions
When writing async functions, it's vital to understand their implications on your code's behavior. A good practice is to avoid async functions that don't contain an await
statement. Tools like ESLint's require-await rule can help enforce this guideline, ensuring more predictable and efficient code.