I find Promise
in JavaScript complicated to work with.
I understand how they work, and I know how to use them, but they are not ‘smooth’ for me.
Contrast that to concepts like, say, ‘calling a function’ or ‘scope’.
Those are also complex things, but while I am merrily coding away, I rely on a simple mental model, and I can blindly use those concepts without needing to think deeply about them and the complexities involved.
While coding, whenever I need to use a Promise
, I invariably find myself coming to a grinding halt, getting out of ‘the zone’, and having to spend thinking time to make sure I grok my own code.
I think I now figured out why Promise
rubs me the wrong way, and how to remedy that by using a mental model I am calling coderstate.
Coderstate is not something I use to code; instead it is a ‘mental state’ in my coder brain to track what I can and cannot do at some point in the code.
I currently distinguish between the following coderstates: global, module, async, function, procedure, promisor, executor, resolver.
Mentally keeping track of the coderstate helps me know when or why to use return
and how to ‘pass on’ data.
Introduction
JavaScript is a language built with asynchronous operations at its heart, primarily handled through Promises
and the newer async
/await
syntax.
While async
/await
simplifies asynchronous code, understanding the fundamental mechanism of Promises
is crucial for any JavaScript developer looking to write efficient, performant, and scalable applications.
Coderstate
To demystify Promise
, and help me work with them, I introduced a concept that I call coderstate.
Coderstates map to distinct behavioral zones within Promise
handling in JavaScript, each with specific roles and expectations.
It is an attempt to give me an easy ‘mental model’ that I can rely on when working on code with Promise
.
First some pseudocode with annotated areas to exemplify the coderstate.
As I read through code like the pseudocode below, I now mentally keep track of the coderstate. It helps me figure out ‘where I am at’, and how to handle data at that point in the code.
Two important aspects are:
– should or shouldn’t I use return
?
– how should I ‘pass on’ the data I have?
Look for comments // ** coderstate: ...
:
// ** coderstate: global or module
let apiEndpoint = "https://api.example.com";
function appendX(s) {
// ** coderstate: function
return s + "X";
}
function logIt(s) {
// ** coderstate: procedure
console.log(s);
}
async function manageUserData(userId) {
// ** coderstate: async
// In coderstate async, I can use "await" keywords
let userData;
try {
userData = await getUserData(userId);
console.log("User Data Processed:", userData);
} catch (error) {
console.error("Error handling user data:", error);
}
return userData;
}
function getUserData(userId) {
// ** coderstate: promisor
// A promisor is akin to an async function,
// but does not have the async keyword in
// the declaration. It still aims to return
// a Promise and works like an async function
// for all intents and purposes
return new Promise((resolve, reject) => {
// ** coderstate: executor
// Nested inside this promisor,
// we find this executor coderstate
fetchData(userId, resolve, reject);
});
}
function fetchData(userId, resolve, reject) {
// ** coderstate: executor
// This section is also part of the executor:
// the ultimate goal is to call the
// resolve or reject functions,
// either now, or some time in the future
console.log("Fetching data for user ID:", userId);
fetch(`${apiEndpoint}/user/${userId}`)
.then(response => response.json())
.then(
(data) => {
// ** coderstate: resolver
// Here we're inside the function that is
// called when the Promise resolves
// We can return plain values or we can return
// a chained Promise. We can also call and return
// the values of the outer reject or resolve functions
if (data.error) {
return reject(
"Failed to fetch data: " +
data.error);
} else {
return resolve(processData(data));
}
},
(reason) => {
// ** coderstate: resolver
// Here we're inside the function that is called
// when the promise rejects, which is a form of
// resolution too
}
})
.catch(
(reason) => {
// ** coderstate: resolver
return reject(
"Network error: " +
error);
}
);
}
function processData(data) {
// ** coderstate: function
console.log("Processing data...");
return data;
}
manageUserData(12345);
coderstate: global or module
Top-level code in a script or module.
Can declare variables or functions and initiate asynchronous operations.
coderstate: function
Inside a regular function; using return
is expected to return some value. Returns an implicit undefined
if there is no return
.
coderstate: procedure
Inside a regular function; no return
is expected – i.e. the caller will ignore the return
value.
coderstate: async
Inside an async function.
Can use await
to pause function execution until a Promise
resolves, simplifying the handling of asynchronous operations.
Whatever we return will become a Promise
once it is received by the caller.
coderstate: promisor
This state signifies a standard, non-async function that aims to return a Promise
.
These promisor functions can be called by asynchronous code – to the caller they look like async function
.
It’s all about how you can look at some code – promisors are not a ‘programming thing’, more like an ‘understanding/expecting thing’.
Promisors behave pretty much the same as async
functions and can be called with await
.
If the return value of a Promisor is not a Promise
, the await-ing code will automatically wrap it with a resolved Promise
.
We cannot use await
inside the promisor because the function is not explicitly declared as async
– we need to chain promises with then
.
function fetchUserData(userId) {
// ** coderstate: promisor
return new Promise((resolve, reject) => {
// ** coderstate: executor
if (userId < 0) {
// Here we use 'return' to abort the execution
// of the executor function.
// Without 'return', we would also execute the
// resolve call further down
return reject(new Error("Invalid user ID"));
}
resolve("User data for " + userId);
});
}
coderstate: executor
An executor is a function passed in to the constructor of a new Promise
object.
It accepts a resolve
and a reject
parameter, both of which are functions.
An executor is a coderstate that has access to either the resolve
or the reject
functions and whose job it is to eventually call resolve
or reject
.
When calling a nested function from an executor, where we also gets provide the resolve
and/or reject
parameters, I will also consider the scope of this nested function to also be in the executor coderstate. See fetchData
in the snippet below for an example.
const promise = new Promise(
(resolve, reject) => {
// ** coderstate: executor
fetchData(userId, resolve, reject);
}
);
function fetchData(userId, resolve, reject) {
// ** coderstate: executor
// fetchData is considered part of the executor
// coderstate because it can directly call resolve or
// call reject.
fetch('https://api.example.com/data')
.then(response => resolve(response.json()))
.catch(error => {
// ** coderstate: resolver
return reject(new Error("Network error: " + error.message));
});
});
Be careful with return
: a return
statement can be used inside an executor, but it is not used to return any useful data to a caller.
return
can only be used to force an early exit from the executor function.
From within an executor, any result data is ‘passed on’ by way of parameters when calling resolve
or reject
, not by way of return
.
I have a more extensive code sample further down to clarify this.
In a good executor, we need to make sure all code paths eventually end in calling either resolve
or calling reject
.
Note that a common pattern is to use return reject(...)
or return resolve(...)
.
This can be slightly confusing. It is important to understand that in coderstate executor, data is passed on by way of the parameter values of these function calls.
The return
statement merely forces an early exit from the executor code flow and the caller will not use any of the returned data.
Contrast this with coderstate resolver where using the return
statement is crucial to pass on the data.
coderstate: resolver
This state is when we’re executing the resolve
or reject
call from a Promise
.
Data is passed in as parameters to the resolver function.
Data can be ‘passed on’ by way of the return
statement.
This is important: in a coderstate resolver, the return
statement is instrumental in passing on data, whereas in coderstate executor, the return
statement plays no role in passing on any data.
From coderstate resolver, we can chain on additional Promise
. We can either return
the final ‘plain’ value or we return
a chained Promise
.
More Complicated Example
In this example, pay attention to when return
is needed or not.
In this example we have a fast flurry of multiple coderstates, and knowing which is which can help us understand when we need return
and when we can omit it.
function appendX(s,m) {
// ** coderstate: promisor
return new Promise(
(resolve, reject) => {
// ** coderstate: executor
if (m == 1) {
// Note: Data is passed on via resolve(). No need for "return"
// We still could use "return" to force early return from code
// and avoid trailing code execution, but any data returned is ignored
resolve(s + ":m=" + m);
}
else {
setTimeout(
() => {
// ** coderstate: procedure
// No return value is expected here, so I see this as a procedure
// Note: Data is 'passed on' via resolve(). No need for "return"
resolve(s + ":m=" + m),
},
1000);
}
}
);
}
function nested(s, m) {
// ** coderstate: promisor
// We don't see an explicit new Promise() here, but because appendX
// is either a promisor or async, this function also becomes a promisor.
return appendX(s, m).then(
(result) => {
// ** coderstate: resolver
// Here, the "return" statement is required to pass on the data
return result + "Chained";
}
)
}
await nested("xx",1);
Comparing Promises with async/await
While async
/await
is syntactically easier and cleaner, using Promise
s directly gives developers finer control over asynchronous sequences, particularly when handling multiple concurrent operations or complex error handling scenarios.
Conclusion
Understanding and utilizing the different “coderstates” of Promise
-related code in JavaScript can make it easier to follow the logic of async JavaScript code.