Callback and Promises

Vitthal KorvanVitthal Korvan
11 min read

Callback Function

Definition

A callback is a function that you pass into another function as an argument. The function receiving the callback will call it (execute it) at some point in its process, either immediately, or after some event has occurred. Callbacks are widely used in JavaScript, especially for handling asynchronous operations like network requests, timers, event handling, and more.

Example

function greet(name) {
    console.log('Hello, ' + name);
}

function processUserInput(callback) {
    const name = prompt('Please enter your name.');
    callback(name);
}

processUserInput(greet);

In this example:

  • greet is the callback function that is passed to processUserInput.

  • The processUserInput function then calls the greet function, passing the user input (name) as an argument.

Synchronous Callbacks vs Asynchronous Callbacks

Synchronous Callbacks

A synchronous callback is executed immediately, as part of the main program flow. That means it happens during the execution of the function that calls it.

Example:

function logMessage(message) {
    console.log(message);
}

function process(callback) {
    // do some synchronous task
    callback('Task is done!');
}
process(logMessage);

In this case, the callback (logMessage) is invoked immediately after the synchronous task is completed.

function getTwoNumbersAndAdd(number1, number2, onSuccess, onFailure) {
  if (typeof number1 === "number" && typeof number2 === "number") {
    onSuccess(number1, number2);
  } else {
    onFailure();
  }
}

function addTwoNumbers(num1, num2) {
  console.log(num1 + num2);
}

function onFail() {
  console.log("Wrong data type");
  console.log("please pass numbers only");
}
getTwoNumbersAndAdd(4, 4, addTwoNumbers, onFail);

Asynchronous Callbacks

An asynchronous callback is invoked after a certain task completes, usually after a delay, or after an asynchronous task such as fetching data or reading a file.

//Using SetTimeout
function logMessage() {
    console.log('This message is displayed after 2 seconds');
}

setTimeout(logMessage, 2000);  // Callback after 2000ms (2 seconds)

Here, setTimeout is an asynchronous function that waits for 2 seconds, then calls logMessage.

Asynchronous operations, such as making network requests, are a common use case for callbacks. Here's an example using a basic callback to simulate fetching data from an API.

//Using Asynchronous Example
function fetchData(callback) {
    setTimeout(() => {
        const data = { name: 'Vitthal', age: 24 };
        callback(data);
    }, 1000);  // Simulate 1-second delay for fetching data
}

function displayData(data) {
    console.log(`Name: ${data.name}, Age: ${data.age}`);
}

fetchData(displayData);

In this case:

  • fetchData is an asynchronous function that simulates fetching data from a server with a 1-second delay.

  • Once the data is "fetched", it calls displayData, which is passed as a callback function to fetchData.

Callback Hell

Callback Hell refers to a situation where multiple asynchronous operations depend on each other, leading to deeply nested callbacks. This makes the code hard to read, understand, and maintain.

  • Readability: The deeper the nesting, the harder it becomes to read and understand the flow.

  • Maintainability: If you need to add or change any logic, it becomes difficult to modify the code without breaking something.

  • Error Handling: Managing errors in such deeply nested structures is challenging, leading to complicated code to handle failure cases.

Pyramid of Doom

A Pyramid of Doom refers to deeply nested code, often seen in asynchronous programming where multiple callbacks are involved. The structure looks like a pyramid because of the increasing indentation level with each nested callback. This pattern makes code harder to read, debug, and maintain.

Promise

A Promise is an object that represents the eventual completion (or failure) of an asynchronous operation and its resulting value. When you create a promise, it can be in one of three states:

  1. Pending: The initial state, neither fulfilled nor rejected.

  2. Fulfilled: The operation completed successfully, and the promise now holds a result value.

  3. Rejected: The operation failed, and the promise holds a reason for the failure (typically an error).

Syntax

let promise = new Promise(function(resolve, reject) {
    // asynchronous task (e.g., fetching data)
    // If success, call resolve(value)
    // If failure, call reject(error)
});
  • resolve(value): This is called when the asynchronous operation completes successfully, and it moves the promise from the "pending" state to the "fulfilled" state. The value is passed to the next .then() handler.

  • reject(reason): This is called when the asynchronous operation fails, moving the promise to the "rejected" state. The reason (typically an error) is passed to the .catch() handler.

Example

let promise = new Promise((resolve, reject) => {
    setTimeout(() => {
        let success = true;
        if (success) {
            resolve("Operation Successful!");
        } else {
            reject("Operation Failed.");
        }
    }, 2000);  // Simulating async work with a timeout
});

promise
    .then(result => console.log(result))  // Handles fulfillment
    .catch(error => console.log(error));  // Handles rejection

In this example, after 2 seconds, the promise will either resolve with the message "Operation Successful!" or reject with the message "Operation Failed.".

/* ------- produce --------*/
console.log("script start");

setTimeout(() => {
  console.log("hello from setTimeout");
}, 0);

const bucket = ["coffee", "chips", "salt", "rice"];

const friedRicePromise = new Promise((resolve, reject) => {
  if (
    bucket.includes("vegetables") &&
    bucket.includes("salt") &&
    bucket.includes("rice")
  ) {
    resolve({ value: "friedrice" });
  } else {
    reject("could not do it");
  }
});

/* -------  Consume --------*/

friedRicePromise
  .then(
    // jab promise resolve hoga
    (resolve) => {console.log("lets eat ", resolve)})
  .catch((error) => {console.log(error)});

for (let i = 0; i <= 100; i++) {
  console.log(Math.floor(Math.random(), i));
}

console.log("script end!!!!");

Function Returning a Promise

// function returning promise

function ricePromise() {
  const bucket = ["coffee", "chips", "vegetables", "salts", "rice"];
  return new Promise((resolve, reject) => {
    if (
      bucket.includes("vegetables") &&
      bucket.includes("salt") &&
      bucket.includes("rice")
    ) {
      resolve({ value: "friedrice" });
    } else {
      reject("could not do it");
    }
  });
}

ricePromise()
  .then(
    // jab promise resolve hoga
    (myfriedRice) => {
      console.log("lets eat ", myfriedRice);
    }
  )
  .catch((error) => {
    console.log(error);
  });

The Lifecycle of a Promise

When a promise is created, it starts in the pending state. Then, based on the outcome of the asynchronous task:

  • If the task completes successfully, the promise is resolved (fulfilled).

  • If the task encounters an error or fails, the promise is rejected.

Once a promise is either fulfilled or rejected, it becomes settled, meaning it won't change state anymore.

Promise Methods

  1. then(onFulfilled, onRejected)

    • The then method takes two arguments: one for handling the successful resolution (onFulfilled) and one for handling rejection (onRejected).

    • You can chain multiple then handlers.

Example:

    promise.then(result => {
        console.log(result);  // "Operation Successful!"
    });
  1. catch(onRejected)

    • The catch method is a shortcut to handle promise rejection. It's equivalent to .then(null, onRejected).

Example:

    promise.catch(error => {
        console.error("Error:", error);
    });
  1. finally(callback)

    • The finally method is executed once the promise is settled (whether fulfilled or rejected). It doesn't take any arguments and is typically used for cleanup actions.

Example:

    promise.finally(() => {
        console.log("Operation finished, regardless of success or failure.");
    });

Chaining Promises

One of the most powerful features of promises is chaining, which allows you to perform a sequence of asynchronous operations in a clean, readable manner.

let promise = new Promise((resolve, reject) => {
    resolve(1);
});

promise
    .then(result => {
        console.log(result);  // 1
        return result * 2;
    })
    .then(result => {
        console.log(result);  // 2
        return result * 3;
    })
    .then(result => {
        console.log(result);  // 6
    });

Each then returns a new promise, so you can chain multiple then calls to handle a series of asynchronous actions.

function myPromise() {
  return new Promise((resolve, reject) => {
    const variable = true
    if(!variable)resolve("foo");
    else reject('no foo')
  });
}

myPromise()
  .then((value) => {
    console.log(value);
    value += "bar";
    return value;
    //return Promise.resolve(value)
  })
  .then((value) => {
    console.log(value);
    value += "baaz";
    return value;
  })
  .then((value) => {
    console.log(value);
  })
  .catch(value=>{
    console.log(value);
  })

Error Handling with Promises

Promises make it easier to manage errors in asynchronous code. If an error occurs in any part of a promise chain, the control is passed down to the nearest .catch().

new Promise((resolve, reject) => {
    throw new Error("Oops!");
}).catch(error => {
    console.log("Caught:", error);  // "Caught: Error: Oops!"
});

You can also catch errors that occur in any part of the chain:

let promise = new Promise((resolve, reject) => {
    resolve("Start");
});

promise
    .then(result => {
        console.log(result);  // "Start"
        throw new Error("Something went wrong");
    })
    .catch(error => {
        console.log("Caught:", error);  
      // "Caught: Error: Something went wrong"
    });

Callback Hell to Flat Code Using a Promise

Async / Await

Async/Await in JavaScript is a modern way to handle asynchronous operations, allowing you to write asynchronous code that looks synchronous, making it easier to read and maintain. It was introduced in ES8 (ES2017) and is built on top of promises, allowing you to avoid the complexity of using .then() and .catch().

Key Concepts

  1. Async Functions:

    • An async function is a function that returns a promise, and inside of it, you can use the await keyword.

    • You define an async function by placing the async keyword before the function declaration.

  2. Await:

    • await pauses the execution of the function until the promise is resolved or rejected.

    • It can only be used inside async functions.

    • The result of an awaited promise is the value the promise resolves to.

Basic Structure

  1. Async function declaration:
async function myAsyncFunction() {
    // Your async code here
}
  1. Await:

    • Use await to pause execution of the async function until a promise is resolved:
async function fetchData() {
    let response = await fetch('https://api.example.com/data'); 
    // Wait for fetch to complete
    let data = await response.json(); 
    // Wait for the response to convert to JSON
    console.log(data); 
    // Now we can use the data
}
  1. Error Handling:

    • You can handle errors in async/await with try/catch blocks.
async function fetchData() {
    try {
        let response = await fetch('https://api.example.com/data');
        if (!response.ok) {
            throw new Error('Network response was not ok');
        }
        let data = await response.json();
        console.log(data);
    } catch (error) {
        console.error('Error:', error);
    }
}

How Async/Await Works

  1. Async Functions Always Return a Promise: An async function automatically wraps its return value in a promise. So, if you return a value from an async function, it is wrapped in Promise.resolve(). If an error is thrown, it’s wrapped in Promise.reject().

     async function sayHello() {
         return "Hello"; 
         // This is equivalent to: return Promise.resolve("Hello")
     }
    
     sayHello().then(result => console.log(result)); // Output: Hello
    
  2. Await is Used to Unwrap a Promise: The await keyword makes JavaScript wait for a promise to settle (either resolved or rejected) and returns the result. If the promise is rejected, it throws the error, which you can handle with try/catch.

     async function getResult() {
         let result = await Promise.resolve('Success!');
         console.log(result); // Output: Success!
     }
    

Example: Simple API Request with Async/Await

Here’s a simple example using async/await to fetch data from an API and handle errors.

async function getData() {
    try {
        let response = 
        await fetch('https://jsonplaceholder.typicode.com/posts/1');
        if (!response.ok) {
            throw new Error('Failed to fetch data');
        }
        let data = await response.json();
        console.log(data);  // Data from the API
    } catch (error) {
        console.error('Error:', error);  // Error handling
    }
}

getData();

In the code above:

  • The fetch call is wrapped with await, so the function waits for the network request to complete before moving on.

  • We check for network errors and use a try/catch block to handle failures.

Handling Multiple Promises

You can use Promise.all() with async/await to wait for multiple asynchronous operations to complete in parallel.

async function fetchMultipleData() {
    try {
        let [response1, response2] = await Promise.all([
            fetch('https://jsonplaceholder.typicode.com/posts/1'),
            fetch('https://jsonplaceholder.typicode.com/posts/2')
        ]);

        let data1 = await response1.json();
        let data2 = await response2.json();

        console.log(data1, data2);
    } catch (error) {
        console.error('Error:', error);
    }
}
fetchMultipleData();

In this case, Promise.all is used to run the fetch requests concurrently (i.e., at the same time), and the await waits until both requests have completed.

Error Handling in Async/Await

Errors in async functions behave similarly to errors in synchronous code. If a promise is rejected, it throws an error, which you can catch using a try/catch block.

async function getDataWithErrorHandling() {
    try {
        let response = await fetch('https://some-invalid-url.com');
        let data = await response.json();
        console.log(data);
    } catch (error) {
        console.error('Caught an error:', error);
    }
}
getDataWithErrorHandling();

If an error occurs in the fetch request, it will be caught and logged.

Async/Await vs. Promises

Before async/await, we used to chain .then() and .catch() with promises. While this works well for small examples, it can quickly become unreadable with complex logic (known as "promise hell" or "callback hell").

Promise chaining:

fetch('https://jsonplaceholder.typicode.com/posts/1')
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => console.error('Error:', error));

Async/await:

async function getData() {
    try {
        let response = await 
        fetch('https://jsonplaceholder.typicode.com/posts/1');
        let data = await response.json();
        console.log(data);
    } catch (error) {
        console.error('Error:', error);
    }
}
getData();

Both methods achieve the same thing, but async/await tends to be cleaner and more readable, especially for complex workflows.

Using Async/Await with Functions

You can also use async/await within other types of functions, like arrow functions:

const fetchData = async () => {
    let response = await fetch('https://api.example.com/data');
    let data = await response.json();
    return data;
};
fetchData().then(data => console.log(data));

Or inside an object method:

const myObject = {
    async fetchData() {
        let response = await fetch('https://api.example.com/data');
        let data = await response.json();
        return data;
    }
};
myObject.fetchData().then(data => console.log(data));

Parallel Execution vs Sequential Execution

When you want to perform independent asynchronous operations in parallel, you should avoid using await directly on each call in sequence. Instead, use Promise.all() to make them run concurrently:

Sequential execution (slower):

async function fetchSequential() {
    let data1 = await fetch('https://jsonplaceholder.typicode.com/posts/1');
    let data2 = await fetch('https://jsonplaceholder.typicode.com/posts/2');
    // Waits for data1 to resolve before fetching data2
}

Parallel execution (faster):

async function fetchParallel() {
    let [data1, data2] = await Promise.all([
        fetch('https://jsonplaceholder.typicode.com/posts/1'),
        fetch('https://jsonplaceholder.typicode.com/posts/2')
    ]);
    // Both requests happen at the same time
}

GitHub Link : GitHub - JavaScript Mastery

2
Subscribe to my newsletter

Read articles from Vitthal Korvan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Vitthal Korvan
Vitthal Korvan

🚀 Hello, World! I'm Vitthal Korvan 🚀 As a passionate front-end web developer, I transform digital landscapes into captivating experiences. you'll find me exploring the intersection of technology and art, sipping on a cup of coffee, or contributing to the open-source community. Life is an adventure, and I bring that spirit to everything I do.