Understanding Callbacks:
The behavior of callbacks depends on the type of callback and the context in which it is used.
Synchronous Callbacks:
When a function with a synchronous callback is executed, both the main function and its callback are pushed onto the call stack. The callback is executed in the context of the function that provided it, and the call stack is cleared after both the main function and the callback are complete.
Example:
function mainFunction(callback) {
console.log("Main function");
callback();
}
function callbackFunction() {
console.log("Callback function");
}
mainFunction(callbackFunction);
In this example, both the "Main function" and "Callback function" will be logged, and the call stack will be cleared.
Asynchronous Callbacks:
Asynchronous callbacks, such as those used with setTimeout or event listeners, are typically handled differently. When an asynchronous operation is encountered, it is moved to the Web APIs (or other environments in the case of Node.js) provided by the runtime environment. The callback associated with the asynchronous operation is placed in a queue (e.g., the task queue in browsers). The event loop continually checks the call stack and the queue. When the call stack is empty, it takes the first callback from the queue and pushes it onto the call stack for execution.
console.log("Start");
setTimeout(function() {
console.log("Inside setTimeout");
}, 1000);
console.log("End");
In this example, "Start" and "End" will be logged immediately, and "Inside setTimeout" will be logged after a 1000-millisecond delay when the callback is picked up from the queue and pushed onto the call stack.
So, while synchronous callbacks directly enter the call stack, asynchronous callbacks are typically handled through event-driven mechanisms and may not immediately enter the call stack.
Callbacks: Immediate & Delayed
Let's start with one of the simplest expressions of a callback (in an asynchronous fashion), in the below code snippet we pass the function as a callback to another facade JavaScript function called setTimeout
which is a part of the Web APIs within the JavaScript engine of the browser.
setTimeout(function() {
conosle.log("callback !")
}, 1000);
Many people describe the behavior of the above function as "set up a timeout and then after 1000 milliseconds execute the conosle.log statement"
Unfortunately, that is an insufficient description, as it lacks the clarity needed to understand, reason, describe, and articulate the actual occurrences accurately. There is a deviation here on how we think and how actually the code is working, this may lead to bugs later. So let's understand it in a detailed way.
The provided code involves two main parts. First, there's the setTimeout function, and second, there's the callback function inside it. Think of it as splitting tasks into immediate actions and delayed actions.
Immediate Action:
The code outside the setTimeout (including the setTimeout call itself) is like your immediate to-do list. It gets done right away without waiting. In this case, the immediate task is to set up a timer for 1000 milliseconds (1 second) and move on.
Delayed Action (Callback):
Inside the setTimeout, there's a function (the callback) with a job to do, but it patiently waits for the timer to finish counting down. After 1000 milliseconds, the callback function gets its turn and performs its task, which is to log "callback !" to the console.
In a nutshell, it's like a continuation, telling your computer "Hey, set a timer for 1 second and defer that callback to happen until later. While you're counting down, I'll do other things. When the timer beeps, we pick up and continue to execute this specific task (log 'callback !' to the console)." This separation of immediate and delayed actions is what makes the code work asynchronously.
Callback Hell:
Callback hell is usually thought of as having something to do with this idea of nesting as shown in the below example, also referred to as the pyramid of doom because of the pyramidal shape of our code. But do you think is that what callback hell really is?
setTimeout(function() {
console.log("One");
setTimeout(function() {
console.log("Two");
setTimeout(function() {
console.log("Three");
}, 1000);
}, 1000);
}, 1000);
We can re-write the above code in a different style as shown below, also called a continuation passing style and we can see that this code does not look like it is nested or in a pyramidal shape, but most people who take a glance at this code would naturally or instinctively not think of a callback hell.
function logThree() {
console.log("Three");
}
function logTwo() {
console.log("Two");
setTimeout(logThree, 1000);
}
function logOne() {
console.log("One");
setTimeout(logTwo, 1000);
}
setTimeout(logOne, 1000);
Callback hell is a programming phenomenon that extends beyond mere issues of indentation and nesting. To grasp its essence, it's crucial to delve into two fundamental challenges associated with callback hell:
Inversion Of Control:
The inversion of control means there is a part of my program that I'm in control of executing and then there is another portion of my code that I'm not in control of executing.
The way that we express that is to take the first half of our code that executes immediately and give the second half of our code as a callback, the callback goes to somebody else who will then become in charge of executing our callback. That's what inverts the control and it puts them in control of when and in what manner to execute the second half of my program.
//above code...
// calling setTtmeout and giving callback
setTimeout(function(){
// inside callback not in our contol
},1000)
// giving timer of 1000 milliseconds to setTtmeout
// below code
Now setTimeout
is a built-in javascript utility so we don't have a trust issue around the setTimeout
not executing our callback function, but let's, imagine relying on a third-party utility for order analytics in an e-commerce system. This utility requires a data object for each order and a callback to process payment. Now this third-party utility has complete control over our callback, The catch is, if this third-party utility mistakenly calls our payment callback twice, customers could be charged double. This scenario illustrates the concept of inversion of control, where we hand over control to an external utility, potentially leading to unexpected issues like duplicate charges.
When we pass a callback there is a trust point, we are trusting the function to which we have provided a callback to handle our callback.
Not Reasonable:
Callbacks are hard to reason about, callback hell becomes a problem because it makes the code structure really complicated. It's not just about how the code looks, it's also about how hard it is to understand and manage. When there are too many nested callbacks, it creates a confusing and tangled mess. This mess makes it tough for developers to figure out what's going on, and it becomes a real challenge to keep the code organized and easy to work with as time goes on.
Thunks:
A thunk is simply a function that is used to preserve state, no matter what when or where you call a thunk it will return the same value every time. In a more technical fashion, "a thunk is a function with some closure state keeping track of some value."
Thunk has everything already that it needs to give you some value back, we just need to call it and it gives us our value.
function add(num1,num2) {
return num1 + num2;
}
const thunk = function() {
return add(5,10)
}
thunk() // always will return 15
we may not realize it yet, but this is a fundamental conceptual underpinning for what a promise is: "a wrapper around a value". In Thunk, it is just a wrapper around a function call, whereas in promises it is a much-sophisticated thing.
Asynchronous Thunks:
Asynchronous thunk adds more advantages as it eliminates time as a concern by wrapping around a state, then we can use that thunk to reason about the state without worrying about the presence of the actual value itself.
There are two types of asynchronous thunks called "Lazy Thunk" and "Active Thunk", Let's discuss them in-detail below.
Lazy Thunk:
An asynchronous thunk is a function that doesn't need any arguments passed to it to do its job, except we need to pass it a callback so that we can get the value out.
function addAsync(a,b,cb) {
setTimeout(function(){
const data = a + b;
cb(data)
},1000)
}
const thunk = function(cb) {
addAsync(5,10,cb)
}
thunk(function(sum){
console.log(sum)
})
In the above code, we have an addAsync
function which handles an asynchronous task with the help of setTimeout
. When the asynchronous task is completed we simply call the callback with the data produced from the asynchronous task which makes the data available to the callback.
This will enable us to have a separation of concerns between performing the asynchronous task and being able to reason about the result/data obtained from the asynchronous task. Such that every time we call a thunk and pass in a callback we know we are going to get our value out in our callback.
From the outside world, we do not care whether that value is available immediately or whether it's going to take a while to get us the value, as we can reason about it and perform operations with it, and we know the callback will be called when data/value is ready for us. By wrapping this function around the state and allowing it to be asynchronous in nature we have essentially normalized time out of the equation, we have produced a wrapper around a value that has become time-independent it doesn't matter if the value is there now or if it's going come later we can still use it in the exactly same way.
Active Thunk:
Till now we have only discussed on how we can manage our asynchronous code using a lazy thunk, but lazy thunks don't do the work until you call it the first time, to overcome this we use an active thunk which will immediately start the work when the thunk is created, as shown in the example below.
function getData(url) {
var result, fn
asyncCall(url, function(response){
if (fn) fn(response);
else result = response;
});
return function(cb) {
if(result) cb(result)
else fn = cb
};
}
const getDataThunk = getData("wwww.xyz.co")
getDataThunk(function(result){
console.log(result)
})
The above code defines a function getData
that handles asynchronous calls. When getData
is invoked with a specific URL, it triggers an asynchronous operation. The returned function (stored as getDataThunk
) can be called with a callback to receive the asynchronous result. This approach, known as a active thunk.
To sum it, we began with synchronous thunks, focusing on lazy thunks which wait until called to do their job, recognizing the need for a more efficient approach, we then explored asynchronous thunks. Unlike their synchronous counterparts, asynchronous thunks initiate tasks immediately, this approach simplifies the process, making the code clearer and more efficient. In simpler terms, active thunks provide a faster and more straightforward way to handle asynchronous operations in JavaScript, improving overall code performance.
Promising Futures:
While thunks provide a solution for "time as a concern". promises will build upon this improvement by offering a structured and intuitive approach, by mitigating the problem of inversion of control that often arises with callbacks. Additionally, they incorporate built-in error handling, eliminating the need for intricate error-checking structures and contributing to the overall robustness of asynchronous workflows.
Our exploration of managing asynchronous tasks in JavaScript, from callbacks to thunks, with promises now capturing our attention. Looking ahead, the next blog will delve deeper into promises, exploring their nuanced benefits and practical applications. This exploration promises to provide a comprehensive understanding of how promises revolutionize the landscape of asynchronous programming in JavaScript. Stay tuned for a closer look at the efficiency and maintainability that promises bring to asynchronous code.