Promises Can Get Messy
In the previous article, I talked about chaining promises together to build flows. I talked about how catch was really syntactic sugar and that there were multiple ways to write equivalent flows. Now I’m going to talk about what to do when your promise chains start getting complicated.
Promises are often used when a user clicks a button or some other event happens that causes some work to begin. The logic execution is essentially happening in the background, and after a while, the UI is updated to let the user know that some action has taken place. When this logic is short and simple, it generally is pretty easy to manage. If the logic involves a lot of steps and calls a lot of external services to do the action, the promise chains can become messy and harder to update without breaking the code:
This isn’t too bad, but imagine if there was branching logic, such as skipping some of the steps. It isn’t entirely obvious how you would do that with the promise chains without nesting promises and making things even more complicated.
Async and Await
We can use the async/await keywords to make the logic in the previous example much easier to follow:
The first thing you may notice is that the onSubmit function is now marked as being async. Any function using the await keyword must be marked as being async because the whole function is now wrapped in a promise — a promise that resolves when that function either returns or throws an error. Most of the time, this isn’t a big deal because the button or event that triggered this flow isn’t waiting for the result. Even if the function was called from other logic, that logic would have to use a then to retrieve any fulfillment value that was generated inside a promise. Therefore, using async/await doesn’t typically cause any large disruption to outside logic.
The next thing you will notice is that unlike the previous example, I don’t need to keep passing the formData object from step to step. I had to do it previously to keep the chained promises from executing in parallel (i.e., promise logic executes as soon as possible, despite the implied order with then). Now with await, I don’t have to do that any more.
The next thing you will notice is that I have await prefixed to each call to functions that return promises. What this does is wait for that promise to be resolved and return the fulfillment value. A reject inside of that promise will be thrown as an exception, which is why I have now wrapped the logic in a try/catch. It is not required to use the return value of a promise, but without the await keywords, the promises would all execute in parallel.
If you have done any debugging of ES6 code, you may have found it difficult to step through code involving promises. I wish I could say that it gets easier with async/await, but since the function where you use them gets wrapped in a promise, it still is rather tricky.
Debuggers will jump out of a function when they hit the await keyword because essentially everything after that point will be executed asynchronously. The same thing happens when a promise is created. It’s far easier to debug code with await because you can set a breakpoint on the line following the await, and when the promise is resolved, the code will stop there.
In the example above, we had a series of await statements. If I set a breakpoint on the second await, my debugger will stop once the first promise is resolved. If I step over, the debugger will jump to where the regular flow is currently executing because the line after that breakpoint won’t execute until the second promise is resolved. In other words, you’ll have to set a breakpoint after each await in order to step through the code logically (hitting go each time to keep code executing until the promises are resolved).
When to Use Async/Await
I’ve seen a lot of arguments that async/await effectively makes using then on Promises an anti-pattern (i.e., never use them). I’m not completely sold on that idea — there are still instances where it makes sense to simply have a function return a promise on a long-running operation and have the caller use then to use the return value. You could use await, but in such a simple case, you could be over-complicating things since any function where you use await effectively becomes async, i.e., wrapped in a promise.
These keywords are far more useful when you are chaining a series of promises together and you have conditional flows. Those situations make sense because those flows are often similar to regular logic flows. You can follow the logic without having to think about the asynchronous nature of the flow.
The bottom line is to simply use them for anything that involves more than one asynchronous operation. Fetching some json from an external site doesn’t necessarily need async/await because it is pretty straightforward what is happening when using then. If it involves many processing steps and conditional logic, however, you probably should move to async/await.
In this article, I talked about how promise chains can start becoming complicated and how they don’t handle conditional flows. By using the async/await keywords, you can easily transform these complicated flows into code that looks a lot like normal synchronous code. This makes these flows much easier to manage.
Next week I’ll talk about some of the ways that you can optimize flows that involve many operations that can be done in parallel or operations where it isn’t critical to finish all the operations in order to get the desired result.