DEV Community

Cover image for JavaScript Promises: The Basics You Need to Know
varshini-as
varshini-as

Posted on • Edited on

JavaScript Promises: The Basics You Need to Know

Introduction

JavaScript is single-threaded, like a one-man band, and can only handle one task at a time. So what happens when it faces multiple tasks, like fetching data from an API or waiting for a timer? Without the right tools, this can slow down your app or even cause it to freeze.

Enter Promises—JavaScript’s way of juggling asynchronous tasks without dropping the ball. Promises allow you to write more efficient, cleaner, and easier-to-understand code while avoiding the infamous “callback hell” that often makes code hard to follow.

In this guide, we’ll explore what Promises are, how they work, and why they are key to handling asynchronous tasks in JavaScript.

What is a Promise?

Think of it like ordering a meal at a restaurant. You place your order and don’t just stand by the kitchen waiting for it. You sit back, chat with your friends, or enjoy the ambiance while your food is being prepared in the background. The restaurant promises to bring your meal when it’s ready. And you trust that, eventually, one of two things will happen: either your meal arrives (fulfilled) or the kitchen tells you they can't make it (rejected).

In JavaScript, Promises work in a similar way. When you ask JavaScript to do something that takes time—like fetching data from a server—it returns a Promise. This Promise doesn’t immediately give you the result. Instead, it tells you, “I’ll get back to you when the work is done.” During that time, the rest of your code continues to run. Once the task is complete, the Promise is either:

  • Fulfilled (the task succeeded), or
  • Rejected (the task failed), and you can handle the outcome accordingly.

How Promises work in JavaScript

A Promise is a placeholder for a value you might not have yet. It can be in one of three states:

  • Pending: The task is in progress, and the outcome isn’t known yet.
  • Fulfilled: The task was successful, and the result is available.
  • Rejected: The task failed, and there's an error to handle.

1. Creating a Promise

To create a Promise, you use the Promise constructor, which takes a function (known as the executor) that has two parameters: resolve and reject. The resolve function is called when the Promise is fulfilled, while the reject function is called when it is rejected.

const myPromise = new Promise((resolve, reject) => {
  // Simulating an asynchronous task (e.g., fetching data)
  const success = true; // Simulate success or failure

  if (success) {
    resolve("Operation completed successfully!"); // Fulfill the promise
  } else {
    reject("Operation failed."); // Reject the promise
  }
});
Enter fullscreen mode Exit fullscreen mode

2. Resolving and Rejecting Promises

Once a Promise is created, you can decide its outcome by calling either resolve or reject:

  • resolve(value): Call this function when the asynchronous operation completes successfully. It passes a value to the handlers that are waiting for the Promise to be fulfilled.
  • reject(error): Call this function when the operation fails. It passes an error message to the handlers that are waiting for the Promise to be rejected.

3. Consuming Promises

Once you've created a Promise, the next step is to consume it. JavaScript provides several methods for handling the outcomes of Promises: .then(), .catch(), and .finally(). Each one helps you manage what happens when a Promise is either fulfilled (successful) or rejected (failed).

  • Handling Resolved Promises with .then(): When a Promise succeeds, we use .then() to tell JavaScript what to do with the result. It takes two optional arguments: one for when things go right, and another for when things go wrong (though using .catch() is more common for handling errors).
const fetchData = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve("Data fetched successfully!");
    }, 1000);
  });
};

fetchData()
  .then(result => {
    console.log(result); // Logs: Data fetched successfully!
  });
Enter fullscreen mode Exit fullscreen mode
  • Handling Rejections with .catch(): If something goes wrong in the Promise, .catch() helps you handle that error cleanly. Think of it as your safety net—it catches any rejections and gives you a chance to handle the error.
const fetchWithError = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject("Error fetching data."); // Simulating an error
    }, 1000);
  });
};

fetchWithError()
  .then(result => {
    console.log(result);
  })
  .catch(error => {
    console.error(error); // Logs: Error fetching data.
  });
Enter fullscreen mode Exit fullscreen mode
  • Running Code After Everything Settles with .finally(): The .finally() method is a great way to clean up or run final tasks after the Promise is finished—whether it was successful or not. It’s perfect for actions that should always happen, like closing a loading spinner or clearing resources.
fetchData()
  .then(result => {
    console.log(result); // Logs: Data fetched successfully!
  })
  .catch(error => {
    console.error(error); // Handle error
  })
  .finally(() => {
    console.log("Promise has settled."); // Logs after either success or failure
  });
Enter fullscreen mode Exit fullscreen mode

To be concise:

  • then(): Use this method to handle the resolved value of a Promise.
  • catch(): Use this method to handle errors when a Promise is rejected.
  • finally(): This method runs code after the Promise settles, regardless of the outcome, allowing for cleanup or final actions.

4. Promise Chaining

One of the coolest things about Promises is that you can chain them together. This lets you run multiple asynchronous tasks in sequence—where each task waits for the one before it to finish. This is super handy when tasks depend on each other, like fetching user data first and then grabbing their related posts.

Let's take a look at the following example:

const fetchUserData = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ userId: 1, username: "JohnDoe" });
    }, 1000);
  });
};

const fetchPosts = (userId) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(["Post 1", "Post 2", "Post 3"]); // Simulated posts
    }, 1000);
  });
};

// Chaining Promises
fetchUserData()
  .then(user => {
    console.log("User fetched:", user);
    return fetchPosts(user.userId); // Pass userId to the next promise
  })
  .then(posts => {
    console.log("Posts fetched:", posts);
  })
  .catch(error => {
    console.error("Error:", error);
  });
Enter fullscreen mode Exit fullscreen mode

In this example, the fetchUserData() function first returns a Promise that resolves with user info. Once that Promise is fulfilled, the .then() passes the user data to fetchPosts(), which fetches their posts. By chaining the Promises together, you’re making sure that posts are only fetched after the user data is available.

If something goes wrong at any point, the .catch() at the end will handle any errors. This makes it much easier to manage errors without having to write a bunch of nested code.

Conclusion

In conclusion, Promises are a crucial part of modern JavaScript, enabling developers to handle asynchronous operations in a more structured and efficient way. By using Promises, you can:

  • Simplify the management of asynchronous tasks and avoid callback hell.
  • Chain multiple asynchronous operations to maintain a clear flow of execution.
  • Effectively handle errors with a unified approach.

As you implement Promises in your own projects, you'll find that they not only improve code readability but also enhance the overall user experience by keeping your applications responsive. I hope that this journey through JavaScript's foundational concepts has provided valuable insights for developers. Happy coding!

Top comments (0)