Once, while reviewing some code, I ran into a surprising snippet (that I'm going to share with you in a moment), that, at first, made me think "No way this is going to work", but after testing it in a browser I, quite shocked, had to change my approach to "Why on Earth is this working!?".
The investigation that followed opened my eyes on an unexpected behavior that I found worthy of sharing here.
So what was that snippet? First, let me give you some context.
We're working on a Svelte component and the code is written by a junior developer, who seems familiar with async-await, but doesn't fully comprehend how Promises work yet. In the following code, he's trying to make an HTML element appear in DOM and then perform some operations with it within the same function.
If you're not familiar with Svelte 4 syntax
No worries - it doesn't have anything to do with the topic of this post.
But for the sake of this example I'll briefly explain what's going on in the code snippet below:
- First we declare a reactive variable
show(yes, allletvariables are reactive in Svelte), which controls whether the element is rendered. - Then we declare a variable
elementthat later gets bound to an element in template. So we can expect this variable to be anHTMLElementunless it's not rendered, in which case it'll beundefined.
Now that we understand the context let's see the code snippet:
<script>
let show = false
let element
async function main() {
show = true
await element
const rect = element.getBoundingClientRect()
console.log(rect)
}
</script>
{#if show}
<div bind:this={element}>
...
</div>
{/if}Now, what do you thing the resulting output to the console will be when main() function is called? (I remind you - element is either an HTMLElement or undefined, never a Promise).
Will we see anything logged at all, or will we see an error?
When we assign show = true, it signals Svelte to react to the state change and render the element. And only after it's rendered it's available through the bound variable.
Conventionally, to wait for the element to appear, we would use Svelte's tick function or at least set a really short timeout, but await element is something that just wouldn't work. That's not how JavaScript works, even with reactivity and Svelte's magic you can't await a primitive and expect it to resolve with an updated value. Right?
Wrong! Mindblowingly, this code doesn't throw an error, and prints to the console exactly what the author's expectation was - a DOMRect of the element.
Moreover, if we removed the await element, then it would crash, because now element would be undefined!
Have we been lied to? Can you just await an undefined variable until it gets assigned a value? What's going on here?
Let's figure this out!
The Investigation
Let's consider one more example, this time with a pre-school Vanilla JS question. What's the output of the following code?
let value
function main() {
console.log(value)
}
main()
value = 'hello world'If you answered undefined, of course you're correct.
But would it change anything if we modified the code like below?
let value
async function main() {
await value // 👈 add this
console.log(value)
}
main()
value = 'hello world'Now it prints 'hello world'!
I'm going to be honest with you - I was pretty mind blown at this point, but as always. there turned out to be a rational explanation.
The trick
Yes, the above code works, but not exactly for the perceived reason.
In both examples it makes it look like a primitive undefined value is being awaited until it gets assigned a real value, and only then the async function proceeds with execution.
But the reality is much more prosaic.
In fact what's happening is when await keyword is used with a non-Promise value, JS implicitly transforms it into a Promise that immediately resolves with that value.
A-ha, now that explains everything! In both of our examples, of course, the execution isn't paused until the variables value gets updated. It's simply paused until the newly minted Promise gets resolved and that just happens to be enough time for the variable's value to update.
As far as JS is concerned, the await element or await value in our examples could be replaced with await null and it would work the same.
So...
Can we use it?
While the code turned out to perform correctly thanks to the implicit Promise conversion, and as exciting as it was to unravel this mystery, I still would advise against using this behavior in an actual codebase.
Considering the potential confusion caused by this unconventional usage of await with a primitive (await someVariable or await null or await 22), we should instead use more explicit approaches when we need to pause the execution of an async function.
Worthy Alternatives
There are various ways to wait for the DOM to catch up with state updates.
Framework-specific tick functions
First thing to consider - many frameworks have an "on next tick" function just for this purpose, which guarantees that the framework took care of applying your state changes and the DOM was updated.
For example, Svelte's tick or Vue's nextTick. Using this approach you could rewrite the main() function from the first example like this:
async function main() {
show = true
await tick()
const rect = element.getBoundingClientRect()
console.log(rect)
}Zero ms Timeout
Usually, it's enough to push the next step of execution to the end of the event loop. And that is one thing a zero-ms setTimeout is often used for.
setTimeoutis generally less safe than framework-specific functions and, depending on the scenario, it can make the code unstable, but if you're using a framework (or library) that doesn't have a dedicated "tick" function, you might have no other choice but to utilize timeouts in this type of situations.Not to worry though: using timeouts with 0 ms delay is not considered a bad practice, because it doesn't offset the callback by some magic time period, but instead simply offsets it until the next event loop.
Here's an example of how one could implement the timeout in our first example to achieve the same behavior:
async function main() {
show = true
setTimeout(() => {
const rect = element.getBoundingClientRect()
console.log(rect)
})
}Explicit Instantly Resolving Promise
This approach does exactly the same thing as the initial example, but without the implicit Promise conversion which leaves the readers of such code scratching their heads thinking they're looking at some voodoo magic (and then writing blog posts about it haha).
Instead, it relies on the same functionality but coded explicitly with Promise.resolve():
async function main() {
show = true
await Promise.resolve()
const rect = element.getBoundingClientRect()
console.log(rect)
}Conclusion
In summary, while the unconventional use of await with primitive values might be a fascinating "gotcha" to explore, it's generally recommended to favor clear and straightforward coding practices. Utilizing established techniques like setTimeout() or Promise.resolve() ensures that your code remains comprehensible and maintainable when dealing with asynchronous operations.
Sometimes the way certain things are coded may get mixed with an implicit behavior in such way, that the result makes it seem like there's some black magic involved. But in the end there's always a rational explanation and an improvement to be made to avoid such complexities in the future.
Don't believe everything that you see, and happy coding!