How to Use JavaScript Promises – Callbacks, Async/Await, and Promise Methods Explained

In this in-depth tutorial, we‘ll explore everything you need to know about using promises in JavaScript, including callbacks, async/await syntax, and useful promise methods. By the end, you‘ll have a solid understanding of how to effectively handle asynchronous operations in your code. Let‘s get started!

The Need for Promises in JavaScript

Many operations in JavaScript are asynchronous, meaning they take some time to complete and the code execution continues without waiting for the operation to finish. Common examples include:

  • Making API calls to servers
  • Reading or writing files on a system
  • Setting timers or intervals
  • Querying a database
  • Requesting animation frames in a browser

Asynchronous programming allows your code to remain responsive and not freeze up waiting for slow or time-consuming tasks to complete.

Traditionally in JavaScript, asynchronous operations were handled using callback functions. A callback is simply a function passed as an argument to another function, to be executed later once the asynchronous operation finishes. While callbacks work, they have some drawbacks that promises aim to solve.

Callback Hell and Lack of Error Handling

Asynchronous code relying on callbacks often leads to deeply nested code structures, commonly known as "callback hell". Here‘s an example of making an API call to get a user object, then using the returned data to make two more requests for the user‘s friends and photos:

getUser(userId, (err, user) => {
  if (err) {
    console.error(err);
    return;
  } 

  getFriends(user, (err, friends) => {
    if (err) {
      console.error(err);
      return;
    }

    getPhotos(user, (err, photos) => {
      if (err) {
        console.error(err); 
        return;
      }

      console.log(user, friends, photos);
    });
  });
});

As you can see, the nesting of callbacks makes this code harder to read and maintain. There‘s also repetitive error handling logic in each callback.

Promises were introduced in ES6 as a cleaner, more standardized way of dealing with asynchronous operations and avoid "callback hell".

Creating and Using Promises

A promise represents the eventual completion (or failure) of an asynchronous operation and its resulting value. It exists in one of three possible states:

  1. Pending – the initial state, before the operation completes
  2. Fulfilled – the operation completed successfully
  3. Rejected – the operation failed

Here‘s a basic example of creating a promise that resolves after a certain time delay:

const timeoutPromise = (delay) => new Promise((resolve, reject) => {
  setTimeout(resolve, delay);
});

The Promise constructor takes a function (called the executor) with two arguments: resolve and reject. Call resolve(value) to resolve the promise with a value, or reject(reason) to reject it with a reason.

To use the promise:

timeoutPromise(1000).then(() => {
    console.log(‘1 second has passed‘);
});

The .then() method is called when the promise fulfills. You can chain multiple .then() handlers to transform the value or perform additional async actions.

Handle promise rejection with .catch():

timeoutPromise(1000)
  .then(() => {
    throw new Error(‘oh no!‘);
  })
  .catch((err) => {
    console.error(err); 
  });

Any errors thrown in the promise executor or .then() handlers will be caught by .catch(). It‘s good practice to always include error handling with .catch() when working with promises.

Promise Chaining

Promises really shine when you need to perform multiple asynchronous operations in sequence. Instead of nesting callbacks, you can chain .then() handlers together.

Here‘s an example that simulates getting a user from an API, retrieving their best friend, then making a final API request for that best friend‘s profile:

getUserPromise(userId) 
  .then(user => getBestFriendPromise(user))
  .then(bestFriend => getProfilePromise(bestFriend))
  .then(profile => {
    console.log(‘Best friend profile:‘, profile);
  }) 
  .catch(err => {
    console.error(‘Error getting best friend profile:‘, err);
  });

Each .then() receives the value returned from the previous one, allowing you to elegantly sequence async actions without nesting. The single .catch() at the end handles any errors that may occur along the promise chain.

Creating Reusable Promises

For a promise you want to reuse, wrap the promise code in a function that accepts any necessary parameters. Here‘s an example of a reusable fetchWithRetry promise that attempts an API call a configurable number of times:

function fetchWithRetry(url, retries = 3) {
  return new Promise((resolve, reject) => {
    const wrapper = () => {
      fetch(url)
        .then(res => {
          if (!res.ok) throw new Error(res.status);
          return res.json();
        })
        .then(resolve)
        .catch(err => retries-- > 0 ? wrapper() : reject(err))
    };

    wrapper();
  });
}

The retries parameter allows the caller to customize the max number of attempts. The promise recursively calls itself in the .catch() until it succeeds or runs out of retry attempts.

Reusable promise-based functions promote DRYer, more maintainable code.

Async/Await – A Cleaner Way to Use Promises

While promise chaining is certainly an improvement over callback hell, the ES2017 standard introduced async/await syntax to further simplify working with promises.

The async keyword declares a function as asynchronous and allows using the await operator in it. await can be used to inline the value from a promise, pausing the function execution until the promise settles:

async function getBestFriendProfile(userId) {
  try {
    const user = await getUserPromise(userId);  
    const bestFriend = await getBestFriendPromise(user);
    const bestFriendProfile = await getProfilePromise(bestFriend);
    return bestFriendProfile;
  } catch (err) {
    console.error(‘Error getting best friend profile:‘, err);
  }
}

Instead of chaining .then()‘s, the await operator "waits" for the promises to resolve and returns their values. This allows the asynchronous code to be structured more like synchronous code, improving readability.

Errors from any await‘ed promise are caught by the try/catch, no longer requiring .catch() handlers after each promise.

The async function itself returns a promise resolving to the value returned inside it (or rejecting with any error thrown).

Executing Multiple Promises Simultaneously

Sometimes you need to execute multiple promises at the same time and aggregate their results. The Promise object provides a few methods for this.

Promise.all()

Promise.all() accepts an array of promises and returns a new promise that fulfills when all the input promises fulfill, or rejects if any of the promises reject.

const [users, posts, comments] = await Promise.all([
  fetchUsers(),
  fetchPosts(),
  fetchComments()
]);

Useful when you need the results of multiple independent promises before proceeding. The resulting array retains the order of the input promises, regardless of their resolution order.

Promise.race()

Promise.race() also accepts an array of promises, but fulfills or rejects as soon as one of the promises fulfills or rejects.

const result = await Promise.race([
  fetchData(),
  timeout(5000).then(() => { throw new Error(‘Request timed out‘) })
]);

This can be used to add a timeout to an async operation. In this example if the fetchData() promise takes longer than 5 seconds, the timeout promise will reject, causing the entire race to reject with the ‘Request timed out‘ error.

Promise.allSettled()

Promise.allSettled() is similar to Promise.all(), but waits for all input promises to settle (fulfill or reject) instead of rejecting on the first rejection.

const results = await Promise.allSettled([
  fetchPrimaryData(),
  fetchSecondaryData(),
  fetchTertiaryData()
]);

const fulfilled = results.filter(r => r.status === ‘fulfilled‘);
const rejected = results.filter(r => r.status === ‘rejected‘);

The resulting array contains objects describing the outcome of each promise. This is useful when you want to handle the results of multiple promises individually, without any rejections short-circuiting the entire operation.

In Summary

JavaScript provides several powerful tools for working with asynchronous code:

  • Promises are objects representing the eventual completion or failure of an async operation
  • Promises exist in one of three states: pending, fulfilled or rejected
  • .then() and .catch() are used to handle promise fulfillment and rejection
  • Multiple promises can be chained to execute async operations in sequence
  • Async functions allow using the await operator to inline promise values, improving readability
  • Promise.all(), Promise.race() and Promise.allSettled() facilitate executing multiple promises simultaneously

By leveraging promises, async/await, and the various promise methods, you can write asynchronous JavaScript code that is cleaner, more expressive, and easier to reason about. Promises are quickly becoming the standard for async JavaScript programming, so having a solid grasp on these concepts is essential for any JavaScript developer.

Similar Posts