tutorial // Jul 08, 2022
How to Wrap an Asynchronous JavaScript Function with a Promise
How to write a callback-based function and then convert it to be a Promise-based function that can be called using async/await.
For this tutorial, we're going to use CheatCode's full-stack JavaScript framework, Joystick. Joystick brings together a front-end UI framework with a Node.js back-end for building apps.
To begin, we'll want to install Joystick via NPM. Make sure you're using Node.js 16+ before installing to ensure compatibility (give this tutorial a read first if you need to learn how to install Node.js or run multiple versions on your computer):
Terminal
npm i -g @joystick.js/cli
This will install Joystick globally on your computer. Once installed, next, let's create a fresh project:
Terminal
joystick create app
After a few seconds, you will see a message logged out to cd
into your new project and run joystick start
:
Terminal
cd app && joystick start
After this, your app should be running and we're ready to get started.
Writing a callback-based example function
To begin, we're going to write a function that uses the traditional (dare I say "old school") callback function pattern that was popular before JavaScript Promises arrived. In the project that was just created for you when you ran joystick create app
above, in the /lib
folder, we want to add a new file sayHello.js
:
/lib/sayHello.js
const sayHello = (name = '', options = {}, callback = null) => {
setTimeout(() => {
const greeting = `Hello, ${name}!`;
callback(null, greeting);
}, options?.delay);
};
export default sayHello;
Above, we're writing an example function called sayHello
that uses a callback-pattern for returning a response when it's called. The reason a callback may be used is because the function we're calling needs to do some work and then respond later. Using a callback, we can prevent that function from blocking JavaScript from processing additional calls in its call stack while we wait for that response.
Here, we're simulating that delayed response by calling to setTimeout()
in the body of our function. That setTimeout
's delay is dictated by options we passed to sayHello()
when we call it. After that delay has passed and the timeout's callback function (here, the arrow function being passed to setTimeout()
) is called, we take the name
passed to sayHello()
and concatenate it into a string with Hello, <name> !
.
Once that greeting
is defined, we call the callback()
function passed as the final argument to sayHello
passing null
for the first argument (where the consumer of the function would expect an error to be passed—an undocumented "standard" among JavaScript developers) and our greeting
for the second.
This is all we need for our example. Let's make some better sense of how this is working by putting this to use and then move on to converting sayHello()
to be Promise-based.
Calling the callback-based example function
Now, we're going to open up a file that was already created for us when we ran joystick create app
above: /ui/pages/index/index.js
.
/ui/pages/index/index.js
import ui from '@joystick.js/ui';
const Index = ui.component({
render: () => {
return `
<div>
</div>
`;
},
});
export default Index;
When you open that file, we want to replace the existing contents with the snippet above. This will give us a fresh Joystick component to work with for testing out sayHello()
.
/ui/pages/index/index.js
import ui from '@joystick.js/ui';
import sayHello from '../../../lib/sayHello';
const Index = ui.component({
events: {
'click button': async (event, component) => {
sayHello('Ryan', { delay: 3000 }, (error, response) => {
if (error) {
console.warn(error);
} else {
console.log(response);
}
});
},
},
render: () => {
return `
<div>
<button>Say Hello</button>
</div>
`;
},
});
export default Index;
Expanding this out, we've done two things:
- In the HTML string returned by the
render()
function at the bottom of the component, we've added a<button></button>
tag between the existing<div></div>
tags that we can click to fire our function. - To handle the firing, just above
render()
, we add anevents
object and define an event listener for aclick
event on ourbutton
tag.
To that event listener definition 'click button'
we assign a function which will be called when the click event is detected on the button. Inside, we call to our sayHello()
function which we've imported up top. Calling that function, we pass the three arguments we anticipated when writing the function: name
as a string, an object of options
with a delay
property, and a callback
function to call when our "work" is done.
Here, we want our function to say Hello, Ryan!
after a three second delay. Assuming everything works, because we're using console.log()
to log the response
to sayHello
in our callback function (we expect this to be our greeting
string), after 3 seconds, we should see Hello, Ryan!
printed to the console.
While this works, it's not ideal, as in some contexts (e.g., having to wait on multiple asynchronous/callback-based functions at one time), we run the risk of creating what's know as "callback hell" or infinitely nested callbacks in order to wait on each call to complete.
Fortunately, to avoid that, JavaScript Promises were introduced into the language and alongside them, the async/await
pattern. Now, we're going to take the sayHello()
function, wrap it in a Promise, and then see how it can clean up our code at call time.
Wrapping the callback-based function in a Promise
To write our Promise-wrapped version of sayHello
, we're going to rely on the methods
feature of Joystick components. While this isn't necessary for this to work (you could write the function we're about to write in a separate file similar to how we wrote /lib/sayHello.js
), it will keep everything in context and easier to understand.
/ui/pages/index/index.js
import ui from '@joystick.js/ui';
import sayHello from '../../../lib/sayHello';
const Index = ui.component({
methods: {
sayHello: (name = '', options = {}) => {
return new Promise((resolve, reject) => {
sayHello(name, options, (error, response) => {
if (error) {
reject(error);
} else {
resolve(response);
}
});
});
}
},
events: {
'click button': async (event, component) => {
const greeting = await component.methods.sayHello('Ryan', { delay: 3000 });
console.log(greeting);
// sayHello('Ryan', { delay: 3000 }, (error, response) => {
// if (error) {
// console.warn(error);
// } else {
// console.log(response);
// }
// });
},
},
render: () => {
return `
<div>
<button>Do the Thing</button>
</div>
`;
},
});
export default Index;
Here, we've added another property to the options object passed to our ui.component()
function called methods
. The object assigned here allows us to define miscellaneous functions that are accessible elsewhere in our component.
Here, we've defined a method sayHello
(not to be confused with the imported sayHello
up top) which takes in two arguments: name
and options
.
Inside of the function body, we return
a call to new Promise()
to define a new JavaScript Promise and to that, we pass a function which receives its own two arguments: resolve
and reject
. Inside, things should start to look familiar. Here, we're calling to sayHello
, relaying the name
and options
passed to our sayHello
method.
The idea here is that our method is going to function like a "proxy" or remote control for our original sayHello
function. The difference is that for the callback function, notice that we take in the possible error
and response
from sayHello
, and instead of logging them to the console, we pass them to either reject()
if there's an error, or, resolve()
if we get a successful response back (our greeting
string).
Back down in our click button
handler, we can see this being put to use. We've commented out the callback-based version of sayHello
so we can see the difference.
In front of the function passed to click button
, we've added async
to signify to JavaScript that our code will be using the await
keyword somewhere inside of the function being passed to click button
. If we look at our refactor, we're doing exactly that. Here, from the component
instance automatically passed as the second argument to our event handler function, we call to component.methods.sayHello()
passing in the name
string and options
object we want to relay to the original sayHello
function.
In front of it, we place an await
keyword to tell JavaScript to wait for the Promise returned by our sayHello
method on the component to resolve. When it does, we expect the greeting
string to be passed to resolve()
which will be stored in the const greeting
variable here (in this example, three seconds after calling the method).
Finally, once we get back a result, we console.log(greeting)
. What's nice about this is that we've not only streamlined our code, but we've simplified it enough so that we can call it alongside other Promises without having to nest a bunch of callbacks.
Wrapping up
In this tutorial, we learned how to take an existing callback-based asynchronous function and wrap it with a JavaScript Promise to make calling it use less code and play nicely with other Promise-based asynchronous code. We learned how to define the original callback-based function and put it to use discussing its disadvantages, and then finally, learned how to use Joystick's methods
feature to help us define our Promise-based wrapper function.