This article was originally published at https://maximorlov.com/visual-guide-refactoring-callback-functions/
Are you constantly struggling to keep your code at least halfway understandable while having deeply nested calls everywhere?
Callback trees a million deep are distressing.
Perhaps you're still not comfortable with async/await and you're stuck using promises.
But what if you understood how async/await works, what would you accomplish? A successful job interview, recognition for your skills, or maybe a promotion?
Imagine working with code that's easy to understand and change, how would that change how you feel about your work?
By learning the simple approach of identifying and isolating the individual parts involved in asynchronous code flow, you will avoid introducing bugs in the refactoring process.
You'll learn a new skill that will give you the confidence to turn callback hells into async joys.
A primer on Node.js callback convention
Callbacks can be either synchronous or asynchronous. When talking about asynchronous callbacks in Node.js, the following two points are true in most cases:
- The callback function is always the last argument passed to an asynchronous function, preceded by other arguments (if any):
// The callback function is the last argument to an asynchronous function
asyncFunction(...params, callback);
- If an asynchronous operation fails, the error object will be the first argument passed to the callback function. In case of a success, the error argument will be
null
followed by 0, 1 or more return values:
// An error-first callback function
callback(error, ...results) {
if (error) {
// Handle error
return;
}
// Do something with the result...
}
This error-first callback style has become a standard in the Node.js community. It's a familiar pattern that makes working with asynchronous code easier.
Parts of asynchronous code flow
Asynchronous code can be broken into a few different parts. Identifying and isolating these individual parts before refactoring is key to not breaking your code in the process.
The five parts are:
- Function execution (with arguments, if any)
- Error object
- Return value(s)
- Error handling
- Using return value(s)
Throughout this article, we'll use reading the contents of a file in Node.js as an example. We'll start with the callback approach, then refactor that into a promise, and lastly refactor to use async/await.
Here's an exercise for you — before reading on, try to identify and isolate all five parts in the following code snippet.
Go ahead, I'll wait.
.
.
.
.
.
.
.
.
.
.
Did you correctly identify all parts involved in asynchronous code flow? Compare your answer with the image below:
Note: The
return
keyword is an Return Early Pattern to avoid wrapping and indenting the remaining code in anelse
block. Returning nothing is the same as not using the keyword at all — in both cases the function's return value isundefined
. In the promise & async/await examples further in this article the return keyword is left out, and because that doesn't change the function definition it's safe to exclude it from the error handling part.
Refactoring callback functions to promises
Once you've identified and isolated the individual parts, you're ready to refactor the callback function to use its promise counterpart.
While refactoring, it's important to remember to not change anything internal to the individual parts.
Refactoring a callback function to a promise is done by moving the parts as a whole and putting them together in a different way.
The following animation explains this process visually:
Note: The second
fs.readFile
is the promise-based equivalent from thefs/promises
Node.js API namespace. Most libraries provide promise alternatives to callback functions. In case yours doesn't, or if you're depending on custom legacy code, you can promisify a callback-based asynchronous function.
The parts that are handling the error and using the return value are short one-liners for example purposes. In your situation, they will likely be much bigger, but the principle remains the same — the parts should be moved as a whole unit without modifying them or breaking them apart.
A noticeable difference between callback functions and promises is that error handling (failure) is separated from using the return value (success). This visual separation is a better representation of the two diverging code paths and is, therefore, is easier to work with.
Refactoring promises to async/await
Refactoring callback functions straight to async/await involves multiple steps and will take some practice before you get the hang of it.
It might be easier and less error-prone to add an intermediary step to the refactoring process. First, refactor the callback function to a promise, and only then refactor the promise to use async/await.
This is how the transition from a promise to async/await looks visually:
Notice how much less movement there is compared to the previous animation that went from a callback function to a promise. Because the success and failure parts are separately kept, refactoring a promise to async/await is mostly about changing the syntax.
In case you need to brush up your understanding of async/await, go ahead and give the article a read.
Conclusion
It takes a lot of practice before you're able to effortlessly refactor callback functions into promises & async/await.
By first identifying and isolating the individual parts involved in asynchronous code flow, you're less likely to break your application while refactoring.
Now it's your turn to get rid of nightmare-inducing legacy code and do a long-awaited (pun not intended) cleanup. The codebase will be easier to read, maintain, and most importantly, a joy to work with. ✨
Transform Callbacks into Clean Async Code! 🚀
Tired of messy callback code? Download this FREE 5-step guide to master async/await and simplify your asynchronous code.
In just a few steps, you'll transform complex logic into readable, modern JavaScript that's easy to maintain. With clear visuals, each step breaks down the process so you can follow along effortlessly.
Top comments (0)