DEV Community

Rajat Oberoi
Rajat Oberoi

Posted on • Edited on

JavaScript Main Concepts < In Depth > Part 1

1. Event Loop, Call Stack, Callback Queue

Explained here

2. this Keyword

The value of this in JavaScript depends on how a function is called, not where it is defined.

  • If function is a method i.e. a part of object, then this references the object itself.
const video = {
    title: 'Netflix',
    play() {
        console.log(this);
    }
}

video.play() //{ title: 'Netflix', play: [Function: play] }

video.stop = function() {
    console.log(this)
}

video.stop() //{ title: 'Netflix', play: [Function: play], stop: [Function] }

Enter fullscreen mode Exit fullscreen mode
  • If function is a Regular function --> this reference the global object, i.e. global object in node and window object in web.
function playVideo() {
    console.log(this);
}

// playVideo() //Global object
Enter fullscreen mode Exit fullscreen mode
  • If function is a Constructor function. this references the new object created from this constructor.
function Video(title) {
    this.title = title;
    console.log(this);
}

const newVideo = new Video('Netflix'); //Video { title: 'Netflix' }

// new keyword creates empty object {} then this will reference the new empty object.

Enter fullscreen mode Exit fullscreen mode

*Behaviour of this keyword in Arrow Functions
*

  • Arrow functions do not have their own this context. Instead, they inherit this from the surrounding (lexical) scope where they are defined.
  • This means that the value of this inside an arrow function is the same as the value of this outside of it.
const userProfile = {
    name: 'John Wick',
    designation: 'SSE',
    team: 'Bill Payments',
    getEmployeeDetails() {
        console.log(`Name: ${this.name}, Designation: ${this.designation}`);
    },
    getTeam: () => {
        console.log(`Team for employee: ${this.name} is ${this.team}`);
    }
}


userProfile.getEmployeeDetails(); //Name: John Wick, Designation: SSE
userProfile.getTeam(); //Team for employee: undefined is undefined

Enter fullscreen mode Exit fullscreen mode
//Arrow functions inheriting this keyword value from their surrounding(lexical) scope.

const userProfile = {
    name: 'John Wick',
    designation: 'SSE',
    team: 'Bill Payments',
    getEmployeeDetails() {
        console.log(`Name: ${this.name}, Designation: ${this.designation}`);
        let getTeam = () => {
            console.log(`Team for employee: ${this.name} is ${this.team}`);
        }
        getTeam();
    },
}


userProfile.getEmployeeDetails();

//Output:
//Name: John Wick, Designation: SSE
//Team for employee: John Wick is Bill Payments
Enter fullscreen mode Exit fullscreen mode

Why arrow functions are designed like this?

  • This behavior is intentional and solves a common issue in JavaScript when working with functions, especially when passing them as callbacks or using them within methods.
  • As we understood before, In a regular function, this is determined at runtime based on how the function is invoked. If it’s called as a method of an object, this refers to that object. If it’s called as a standalone function, this defaults to the global object (or undefined in strict mode).

  • This dynamic behavior can be problematic when using functions as callbacks or nested within methods because the context (this) can change unexpectedly.

Example of Issue:

const userProfile = {
    name: 'John',
    showName: function() {
        console.log(this.name); // "John"

        function nestedFunction() {
            console.log(this.name); // `this` refers to the global object, not `userProfile`
        }

        nestedFunction();
    }
};

userProfile.showName();

Enter fullscreen mode Exit fullscreen mode
  • Here, the nestedFunction loses the context of userProfile and points to the global object instead. This can be confusing and often requires using workarounds like storing this in a variable (const self = this) or using .bind().

  • Arrow functions solve this issue by lexically binding this—meaning they capture the this value from their enclosing scope (where they are defined), rather than creating their own.

*Example of Arrow Function Preserving this:
*

The arrow function getTeam correctly retains the this value from the getEmployeeDetails method,

const userProfile = {
    name: 'John Wick',
    designation: 'SSE',
    team: 'Bill Payments',
    getEmployeeDetails() {
        console.log(`Name: ${this.name}, Designation: ${this.designation}`);
        const getTeam = () => {
            console.log(`Team: ${this.team}`);
        }
        getTeam()
    },
}


userProfile.getEmployeeDetails();

Enter fullscreen mode Exit fullscreen mode

< Passing this to self {Use case} >

const hotStar = {
    title: 'Sports',
    tags: ['India Vs Pakistan', 'India Vs USA', 'Ireland VS USA'],
    showTags() {
        this.tags.forEach(function (tag) {
            console.log(this.title, tag);
        })
    }
}

hotStar.showTags()
Enter fullscreen mode Exit fullscreen mode

Output:

undefined 'India Vs Pakistan'
undefined 'India Vs USA'
undefined 'Ireland VS USA'

Reason:

  • When showTags is called, this refers to the hotStar object.
  • However, inside the forEach callback function, function is a regular function and not a method and as explained above, in regular function this references global object (window object in browsers) or be undefined in strict mode.
  • So there is no property in global object named 'title' hence undefined.

To Solve such scenario:

To maintain a reference to the hotStar object within the forEach callback, the showTags method saves the reference in a variable called self(most commonly variable name used is self, we can give any name to it.)

const hotStar = {
    title: 'Sports',
    tags: ['India Vs Pakistan', 'India Vs USA', 'Ireland VS USA'],
    showTags() {
        let self = this;
        this.tags.forEach(function (tag) {
            console.log(self.title, tag);
        })
    }
}

hotStar.showTags()
Enter fullscreen mode Exit fullscreen mode

Output:

Sports India Vs Pakistan
Sports India Vs USA
Sports Ireland VS USA

3. Callbacks

  • General Callback: Any function that is passed as an argument to another function and is executed after some kind of event or action.

  • Asynchronous Callback: A specific type of callback that is executed after an asynchronous operation completes. These are often associated with events or tasks that are scheduled to run in the future, such as I/O operations, timers, or network requests.

function getDBdata(rechargeNumber, cb) {
    console.log(`Executing select query in MySQL for  ${rechargeNumber}`)
    cb(rechargeNumber);
}


function callMerchantApi(recharge_number) {
    console.log(`Calling merchant REST API for ${recharge_number}`)
}

getDBdata('1241421414', callMerchantApi) //callMerchantApi is a callback
Enter fullscreen mode Exit fullscreen mode
  • In this JavaScript code, we have two functions: getDBdata and callMerchantApi.

  • The getDBdata function accepts a rechargeNumber(Customer Number) and a callback function cb. Inside getDBdata, a message is logged to the console, and then the callback function cb is called with rechargeNumber as its argument. Function passed in a function.

  • Another Example

Here below setTimeout is a function for adding a delay, and after timeout/delay of 3 seconds is achieved, callback(which here is a arrow function) gets triggered and console message is printed.

setTimeout(() => { 
    console.log("This message is shown after 3 seconds");
}, 3000);
Enter fullscreen mode Exit fullscreen mode

Major Disadvantage of Callback is Nested Callbacks.

  • Callbacks can lead to deeply nested code (callback hell), which is hard to read and maintain.
  • To address these drawbacks, modern JavaScript offers Promises and the async/await syntax, which simplify asynchronous code and improve readability.

4. Promises

  • In JavaScript, a Promise is an object that represents the eventual completion (or failure) of an asynchronous operation and its resulting value. Promises provide a cleaner, more flexible way to work with asynchronous code compared to traditional callback functions.

A Promise has three states:

Pending: The initial state, neither fulfilled nor rejected.
Fulfilled: The operation completed successfully.
Rejected: The operation failed.

To a promise, we can attach 3 methods:

.then(): Gets called after a promise resolved.
.catch(): Gets called after a promise rejected.
.finally(): Always gets called, whether the promise resolved or rejected.

  • The .then method receives the value passed to the resolve method.
  • The .catch method receives the value passed to the rejected method
getUserFromMySQL(custId)
.then((result) => {
    console.log(result)
})
.catch((err) => {
    console.log(`Something went wrong!! ${err}`)
})
.finally(() => {
    console.log('Promise all done!!')
})
Enter fullscreen mode Exit fullscreen mode

Creating and Using a Promise:


//Creating
let somePromise = new Promise((resolve, reject) => {
    // Some asynchronous operation
    let success = true; // This could be the result of the operation

    if (success) {
        resolve("Operation successful");
    } else {
        reject("Operation failed");
    }
});

//Using

somePromise
    .then(result => {
        console.log(result); // "Operation successful"
    })
    .catch(error => {
        console.error(error); // "Operation failed"
    })

Enter fullscreen mode Exit fullscreen mode

< Promise Execution Behind The Scenes >

  • 1. new Promise Constructor function is called. It also receives a executor function.

new Promise((resolve, reject) => {
//Some Async Stuff Here.
})

    1. New Promise object is created in memory. This object contains some internal slots like PromiseState, PromiseResult, PromiseFulfillReactions, PromiseRejectReactions and PromiseIsHandled. {We cannot access these internal slots}
  • PromiseState will be pending if the promise is not resolved or rejected.

    // Initially:
    // PromiseState: "pending"
    // PromiseResult: undefined
    // PromiseFulfillReactions: []
    // PromiseRejectReactions: []
    // PromiseIsHandled: false

Image description

  • We can resolve or reject by calling resolve or reject that are made available to use by executor function.

  • When we call resolve, the PromiseState is set to fulfilled.

    // After resolve is called:
    // PromiseState: "fulfilled"
    // PromiseResult: undefined --> As we have not passed anything while calling resolve()
    // PromiseFulfillReactions: [onFulfilled handler]
    // PromiseRejectReactions: []
    // PromiseIsHandled: false

Image description

  • Resolving or Rejecting with some data.

Image description

Using .then and .catch

const getCarDetails = new Promise((resolve, reject) => {
    const customerId = 'Elon Musk';
    readBillsFromCassandra(customerId, (error, data) => {
        if (error) {
            reject('DB Client Not Ready!!');
        } else {
            resolve(data);
        }
    });
});

function readBillsFromCassandra(custId, callback) {
    console.log(`Fetching data from Cassandra keyspace for ${custId}`);
    // Simulating async data fetching with a timeout
    setTimeout(() => {
        // Simulate successful data retrieval
        callback(null, {"car": "Tesla", "color": "black", "type": "EV"});
        // To simulate an error, uncomment the following line
        // callback(new Error('Cassandra error'), null);
    }, 1000);
}

getCarDetails
    .then((result) => {
        console.log(`Result of promise is ${JSON.stringify(result)}`);
    })
    .catch((error) => {
        console.log(`Something went wrong, Error: ${error}`);
    });

Enter fullscreen mode Exit fullscreen mode
  • When resolve is called, it gets added to the call stack, Promise state is set to fulfilled, result is set to the value we pass to the resolve, and the promise reaction record handler receives that promise result {"car": "Tesla", "color": "black", "type": "EV"} in above example.

< Code Flow BTS >

Example 1:

new Promise((resolve) => {
    setTimeout(() => {
        resolve('Done!')
    }, 1000);
})
.then((result) => {
    console.log('Result is ', result)
})
Enter fullscreen mode Exit fullscreen mode
  • new Promise constructor added to call stack and this creates the promise object.
  • The executor function (resolve) => { setTimeout(...) } is executed immediately.
  • setTimeout gets added to call stack and registers a event-callback pair in Node API. Timer Starts.

The new Promise constructor completes, and the Promise is now pending, waiting to be resolved.

  • next line, The .then method is called on the Promise object.
  • Timeout achieved, callback we passed to setTimeOut is now added to Task Queue/Callback Queue.
  • From task queue it goes to Call stack and gets executed. Promise state changes to fulfilled.
  • .then handler now moves to micro task queue. (result) => { console.log('Result is', result) } when the Promise is resolved. The callback is added to the micro task queue (also known as the job queue).
  • The JavaScript runtime continues to execute other code (if any) or sits idle, checking the event loop.
  • setTimeout callback is popped out after execution, handler function moves to call stack and gets executed.

  • When our handler was in micro task queue, our other tasks in our code keeps on executing, only when the call stack is empty this handler gets added to it.

  • This means we can handle the promise results in a Non Blocking Way.

  • .then itself also creates a promise record. Which allows us to chain .then to each other, like below.


new Promise((resolve) => {
    resolve(1)
})
.then((result) => {
    return result * 2;
})
.then((result) => {
    return result * 3;
})
.then((result) => {
    console.log(result) //Output: 6
})
Enter fullscreen mode Exit fullscreen mode

Example 2 < Code Flow BTS >


new Promise((resolve) => {
    setTimeout(() => {
        console.log(1)
        resolve(2)
    }, 1000);
})
.then((result) => {
    console.log(result)
});

console.log(3);


Enter fullscreen mode Exit fullscreen mode

Output Sequence
3
1
2

Explanation:

  • Main Execution Context:

new Promise((resolve) => {...}) is encountered, and the Promise constructor is called. Which creates a Promise Object with PromiseState: pending

  • Promise Executor Function:

The executor function (resolve) => { setTimeout(...) } is executed immediately.
setTimeout is called with a callback and a delay of 1000 milliseconds. It registers an event-callback pair.

  • Adding .then Handler:

The .then method is called on the Promise object.
The callback (result) => { console.log(result) } is registered to be called once the Promise is resolved.

  • logging 3 to the console.

console.log(3) is executed immediately.

  • Event Loop and Task Execution:

The JavaScript runtime continues executing other code (if any) or sits idle, checking the event loop.
After 1000 milliseconds, the callback passed to setTimeout is moved from the macro task queue to the call stack.

The callback () => { console.log(1); resolve(2); } is executed.

console.log(1) is executed, logging 1 to the console.

resolve(2) is called, which changes the state of the Promise from pending to fulfilled with the value 2.

The .then callback (result) => { console.log(result) } is enqueued in the micro task queue.

  • Microtask Queue Execution:

After the current macrotask (the setTimeout callback) completes, the event loop checks the microtask queue.

The .then callback (result) => { console.log(result) } is dequeued from the microtask queue and moved to the call stack.

The .then callback is executed with result being 2.
console.log(result) is executed, logging 2 to the console.

5. Async/Await

ES7 introduced a new way to add async behaviour in JavaScript and make easier to work with promises based code!

Image description

First, Let's analyse the behaviour changes when we add a keyword 'async' in front of a function.

const doWork = () => {
    //If we do not return anything by default undefined is returned.
}

console.log(doWork()); //Output: undefined
Enter fullscreen mode Exit fullscreen mode

Now, adding async

const doWork = async () => {
    //If we do not return anything by default undefined is returned.
}

console.log(doWork()); //Promise { undefined }
Enter fullscreen mode Exit fullscreen mode
  • Hence, *Async functions always returns a promise, and those promise are fulfilled by the value developer choose to return from the function. *

Now, lets explicitly return a string.

const doWork = async () => {
    return 'John Wick'
}

console.log(doWork()); //Promise { 'John Wick' }
Enter fullscreen mode Exit fullscreen mode

So the return value of doWork function here is not a string, instead it is a Promise that gets fulfilled with this string 'John Wick'.

  • Since, we are getting a promise in return, we can use .then and .catch methods.
const doWork = async () => {
    //throw new Error('Db client not connected!')
    return 'John Wick'
}

doWork()
Enter fullscreen mode Exit fullscreen mode

Output: My name is John Wick

  • If doWork throws an error, it is same as reject promise, and will be catched. Uncomment throw error to stimulate .catch

Await Operator

Old Way

const add = (a, b) => {
    return new Promise((resolve, reject) => {
        //setTimeOut is used to stimulate Async opearation. Here it can be any thing, like REST API call, or Fetching data from Database.
        setTimeout(() => {
            resolve(a + b)
        }, 2000)
    })
}


add(1, 3)
.then((result) => {
    console.log(`Result received is ${result}`)
})
.catch((err) => {
    console.log(`Received error: ${err}`)
})
Enter fullscreen mode Exit fullscreen mode

With using Async-Await

const add = (a, b) => {
    return new Promise((resolve, reject) => {
        //setTimeOut is used to stimulate Async opearation. Here it can be any thing, like REST API call, or Fetching data from Database.
        setTimeout(() => {
            resolve(a + b)
        }, 2000)
    })
}

const doWork = async () => {
    try {
        const result = await add(1, 3);
        console.log(`Result is ${result}`);

    } catch (err) {
        console.log(`Received error: ${err}`)
    }
}

doWork()
Enter fullscreen mode Exit fullscreen mode
  • This way JavaScript's async and await keywords allow developers to write asynchronous code in a style that resembles synchronous code. This can make the code easier to read and understand.
  • The doWork function is declared with the async keyword, making it an asynchronous function. This allows the use of the await keyword inside it.
  • Within doWork, the await keyword is used before calling add(1, 3). This pauses the execution of doWork until the Promise returned by add is resolved.
  • While the execution is paused, other operations can continue (i.e., the event loop remains unblocked).

Using Async and Await doesn't make things faster, it's just makes things easier to work with. Check below snippet for doWork

const doWork = async () => {
    try {
        const sum = await add(1, 3); //Wait 2 seconds
        const sum1 = await add(sum, 10);//Wait 2 seconds
        const sum2 = await add(sum1, 100);//Wait 2 seconds
        console.log(`Final sum is ${sum2}`) //Final sum is 114
    } catch (err) {
        console.log(`Received error: ${err}`)
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Out all 3 functions calls runs in order, other asynchronous things happening behind the scenes.
  • If first await promise rejects, none of the code down below that would run.
  • await calls operator sequentially, sum2 will not kick off until we get a value for sum. In some cases this is desired.

Taking below example where sum, sum2, sum3 are not dependent

const doWork = async () => {
    try {
        const sum = await add(1, 3);
        const sum2 = await add(4, 10);
        const sum3 = await add(100, 200);
        return `${sum}, ${sum2}, ${sum3}`
    } catch (error) {
        console.log(`Something went wrong! Error: ${error}`)
    }
}
Enter fullscreen mode Exit fullscreen mode

If we want all the sum simultaneously by not blocking each other we can rewrite the above code as:

async function getAllSum() {
    let [
        sum,
        sum2,
        sum3
    ] = await Promise.all([
        add(1, 3),
        add(4, 10),
        add(100, 200)
    ]);
    return `${sum}, ${sum2}, ${sum3}`; 
}
Enter fullscreen mode Exit fullscreen mode

One of the problem with Promise chaining is that, it is difficult to have all values in same scope. For example

add(1, 1)
.then((sum) => {
    console.log(sum)
    return add(sum, 10);
})
.then((sum2) => {
    console.log(sum2)
})
.catch((err) => {
    console.log(err)
})
Enter fullscreen mode Exit fullscreen mode
  • What if we want to have access to both sum at the same time to do something like save in database. We would have to create a variable in parent scope and reassign them in .then.

  • In async await, we have access to all individual sums in the same scope.

click for Part 2

Top comments (0)