DEV Community

Guilherme Araujo for Quave

Posted on

Handling Promises with Higher-Order Functions in JavaScript

When working with arrays in JavaScript, you may find yourself needing to make asynchronous calls inside methods like forEach, .map, or similar functions. But after encountering errors or unexpected outputs, you might wonder, Why isn’t this working as expected? In this article, we’ll dive into these situations and explore how to handle promises effectively within higher-order functions.

What is a higher-order function?

Starting from the beginning:

A higher order function is a function that takes one or more functions as arguments, or returns a function as its result.

A simple example would look like this:

// Higher-order function that returns a function
function createAdder(x) {
  return function(y) {
    return x + y;
  };
}

const addFive = createAdder(5);
console.log(addFive(3)); 
// Console output: 8
Enter fullscreen mode Exit fullscreen mode

What is a Promise?

The Promise object represents the eventual completion (or failure) of an asynchronous operation and its resulting value.

A Promise is an object representing a value that may not be available yet but will be resolved in the future. Promises allow you to attach handlers to manage the eventual success or failure of an asynchronous operation, making it easier to handle responses as they arrive.

This pattern is especially useful in cases like:

  • API calls: When fetching data from external sources, such as RESTful APIs, the data may take time to arrive;
  • Database interactions: Querying a database can also be time-consuming, so promises allow you to handle the data only once it’s available.

Instead of returning the final data instantly, these asynchronous functions return a promise, which acts as a placeholder for the value that will be provided later.

Given these two concepts, let’s see how they interact.

Combining Promises with Higher-Order Functions: Practical Examples

Now that we understand promises, let’s look at a common use case: fetching data, such as retrieving user information from an array of user IDs:

const userIds = [1, 2, 3];

// Simulated async function to fetch user data
const fetchUser = async (id) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(`User ${id} data`);
    }, 1000);
  });
};

Enter fullscreen mode Exit fullscreen mode

❌ The Common Mistake: Not Handling the Array of Promises Properly
When using .map with an asynchronous function, a common mistake is forgetting that map will immediately return an array of promises instead of resolved values. Without additional handling, this leads to unexpected results:

const wrongWay = async () => {
  // This creates an array of promises, not resolved values!
  const users = userIds.map(async (id) => {
    return await fetchUser(id);
  });

  console.log(users); // Output: [Promise, Promise, Promise]
};

Enter fullscreen mode Exit fullscreen mode

In this example, each call to fetchUser(id) returns a promise, so map returns an array of promises rather than the final data values. Simply awaiting inside .map doesn’t resolve the array itself; instead, it just creates a promise for each item in the array.

✅ The Correct Approach: Using Promise.all with map
To properly handle an array of promises, you need to use Promise.all. This method takes an array of promises and returns a new promise that resolves once all the promises in the array are fulfilled. Here’s how to use Promise.all to get the resolved data:

const rightWay = async () => {
  const users = await Promise.all(
    userIds.map(id => fetchUser(id))
  );

  console.log(users); // Output: ["User 1 data", "User 2 data", "User 3 data"]
};
Enter fullscreen mode Exit fullscreen mode

.filter function

Like .map, the .filter function can lead to unexpected behavior when combined with asynchronous functions. Suppose we want to filter an array of user IDs to retrieve only the IDs of users that meet certain criteria, such as users with IDs that are "active."

Let’s take a look at a common mistake and how to avoid it.

❌ The Common Mistake: Using .filter with Async Functions

const isActiveUser = async (id) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      // Simulates checking if a user is active
      resolve(id % 2 === 0); // Returns true for even IDs, false for odd ones
    }, 1000);
  });
};

const wrongFilterUsage = async () => {
  const activeUsers = userIds.filter(async (id) => {
    return await isActiveUser(id);
  });

  console.log(activeUsers); // Output: [1, 2, 3] — the original array, not the filtered results
};

Enter fullscreen mode Exit fullscreen mode

In this example, .filter doesn’t wait for isActiveUser(id) to resolve, so it operates on the original array of IDs rather than the resolved results. The array activeUsers ends up containing all user IDs, regardless of whether they are active, because the async function inside .filter doesn’t return the expected boolean.

✅ The Correct Approach: Using Promise.all with .map and .filter
To properly filter an array with an async function, we need to use .map in combination with Promise.all to resolve all promises first, then apply .filter on the resolved values. Here’s the correct approach:

const correctFilterUsage = async () => {
  // Use Promise.all to resolve the async checks for each user ID
  const results = await Promise.all(
    userIds.map(async (id) => ({
      id,
      isActive: await isActiveUser(id),
    }))
  );

  // Filter based on the resolved isActive status
  const activeUsers = results
    .filter(user => user.isActive)
    .map(user => user.id);

  console.log(activeUsers); // Output: [2] — only active user IDs
};
Enter fullscreen mode Exit fullscreen mode

💡Promise.all
Using Promise.all offers several advantages for handling asynchronous tasks, especially when working with multiple promises simultaneously:

  • Parallel Execution of Requests: One of the primary benefits of Promise.all is its ability to execute multiple promises in parallel. When you pass an array of promises to Promise.all, it initiates all of them at the same time, allowing each promise to start processing concurrently. This can greatly improve performance by reducing the overall wait time, as all tasks are "in progress" together.

Example: If you’re fetching data for multiple users or making multiple API requests, Promise.all allows you to start each request without waiting for others to finish. This is particularly valuable for tasks where the order of execution doesn’t matter, like retrieving data from different sources.

⚠️ Caution: Use Promise.all carefully, as parallel execution may not always be ideal. For cases where tasks depend on one another or need to run sequentially (e.g., writing to a database in a specific order), parallel execution may cause issues. We’ll explore sequential handling methods later on.

  • Simplified Error Handling: Another powerful feature of Promise.all is its error-handling mechanism. If any promise in the array rejects, Promise.all will immediately reject with that error, allowing you to handle failures in a single .catch block. This makes error management more streamlined and efficient.

Note: Only the first rejection triggers the error; the remaining promises will continue to execute but won’t trigger further .catch blocks. If you need to capture each promise's result regardless of failure, consider using Promise.allSettled, which returns an array of outcomes for each promise, detailing whether they resolved or rejected.

  • Aggregated Results: Once all promises in Promise.all resolve, it returns an array of the resolved values, matching the order of the original promises. This makes it easy to work with the final results in a predictable way, even though each promise completes independently.

Summary Table

There are a lot of higher-order functions available in JavaScript, but here is a attempt to create a cheat sheet to make it easier to remember how the most common functions deal with asynchronous operations:

Method Common Issue Correct Solution
.filter Returns array of promises Use Promise.all with .map, then filter synchronously
.forEach Doesn’t wait for async functions to complete Use Promise.all with .map (parallel) or for...of with await (sequential)
.find Doesn’t wait for async condition to resolve Use for...of with await to find the first match sequentially
.reduce Returns promise for each iteration Use async reduce, awaiting the accumulator before each iteration

for...of for Sequential Async Processing

As mentioned earlier, simultaneous processing isn’t always ideal, especially when tasks depend on each other or could overload a server or API. In such cases, handling tasks sequentially can be beneficial, as it ensures that each task completes before the next one begins.

This is where the for...of loop becomes valuable. Unlike .map or .forEach, for...of allows you to await each asynchronous operation in sequence, ensuring that each task completes before moving on to the next. This approach is particularly useful in situations like making API calls that should occur in a specific order, processing a list of database writes sequentially, or handling rate-limited operations where triggering requests all at once could lead to errors.

An example of for...of:

const userIds = [1, 2, 3];

// Async function that simulates an API call
const fetchUserData = async (id) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(`User ${id} data`);
    }, 1000);
  });
};

const fetchAllUserData = async () => {
  const userData = [];

  for (const id of userIds) {
    const data = await fetchUserData(id); // Each call waits for the previous one to complete
    userData.push(data);
  }

  console.log(userData); // Output: ["User 1 data", "User 2 data", "User 3 data"]
};

fetchAllUserData();
Enter fullscreen mode Exit fullscreen mode

In this example, each fetchUserData call only starts after the previous one finishes, ensuring the operations are processed sequentially.

Wrapping Everything up

Working with asynchronous operations inside higher-order functions can be tricky and often leads to misunderstandings and unexpected results. Each method—whether .map, .filter, .forEach, .find, or .reduce —behaves differently with async functions, and recognizing these nuances is key to avoiding common mistakes. By understanding how each function handles promises and when to use tools like Promise.all or the for...of loop, you can significantly improve both the readability and reliability of your asynchronous code.

Hopefully, this guide will help clarify any questions that arise about how functions and promises behave in different scenarios.

Top comments (0)