Thinking about Promises

Promises act as values

You can think of a Promise as an eventual value. This value could be a success value or a failure value. It either succeeds with a particular successful result, or it fails with an error. Importantly, it can’t be both.

Resolve with
Value
Reject with
Error
A new promise that can be either resolved or rejected

It either resolves to a success value:

✅ Resolved with
42
A promise resolved with the value 42
// Succeed with the value 42
Promise.resolve(42);

// Longer version of above
new Promise((resolve, reject) => {
  resolve(42);
});

Or it rejects with an error value:

❌ Rejected with
Error: out of stock
A promise rejected with the error Out of stock
// Fail with the error *out of stock*
Promise.reject(Error("out of stock"));

// Longer version of above
new Promise((resolve, reject) => {
  reject(Error("out of stock"));
});

// Alternate version of above
new Promise((resolve, reject) => {
  throw Error("out of stock");
});

Once a Promise has receive its value, you can’t changed that value. If it was told it failed, it can’t be later told to succeed instead. And if it succeeded, it can’t later fail.

new Promise((resolve, reject) => {
  // Once a promise has been resolved or rejected
  resolve(42);
  // it can’t then be resolved again
  resolve(42); // Invalid!
  // or be rejected later
  reject(Error("out of stock")); // Invalid!
});

Chaining Promises

A Promise can create a new Promise by calling .then() on it.

✅ Resolved with
42
⬇ .then
Multiply value by 2
✅ Resolved with
84
A promise chained to create another promise that has the previous value doubled
Promise.resolve(42)
  .then(value => {
    return value * 2;
  });
  
// Longer version of above
Promise.resolve(42)
  .then(value => {
    return Promise.resolve(value * 2);
  });

The callback to .then() can fail, either by throwing an error, or returning a rejected Promise:

✅ Resolved with
42
⬇ .then
Throw Error: out of stock
❌ Rejected with
Error: out of stock
A promise chained to create another promise that is rejected
Promise.resolve(42)
  .then(value => {
    throw Error("out of stock");
  });
  
// Longer version of above
Promise.resolve(42)
  .then(value => {
    return Promise.reject(Error("out of stock"));
  });

// Alternative version of above
// Note the Promises can be created up-front.
const outOfStockError = Promise.reject(Error("out of stock"));
Promise.resolve(42)
  .then(value => {
    return outOfStockError;
  });

If a Promise fails, any derived Promises will also fail.

✅ Resolved with
42
⬇ .then
Throw Error: out of stock
❌ Rejected with
Error: out of stock
⬇ .then
❌ Rejected with
Error: out of stock
Chaining from a rejected promise produces only rejected promises
Promise.resolve(42)
  .then(value => {
    throw Error("out of stock");
  })
  .then(value => {
    // This will never get called as the previous promise was rejected
  });

A Promise chain can be recovered by calling .catch() and returning another value or Promise.

✅ Resolved with
42
⬇ .then
Throw Error: out of stock
❌ Rejected with
Error: out of stock
⬇ .catch
Return 3
✅ Resolved with
3
Recover from a rejected promise by calling .catch()
export const a = Promise.resolve(42)
  .then(value => {
    throw Error("out of stock");
  })
  .catch(error => {
    return 3;
  });

// Same as above
export const b = Promise.resolve(42)
  .then(value => {
    throw Error("out of stock");
  })
  .catch(error => {
    return Promise.resolve(3);
  });

// Same as above
const fallbackPromise = Promise.resolve(3);
export const c = Promise.resolve(42)
  .then(value => {
    throw Error("out of stock");
  })
  .catch(error => {
    return fallbackPromise;
  });

// Same as above
const promiseThatWillFail = Promise.resolve(42).then(value => {
  throw Error("out of stock");
});
export const d = promiseThatWillFail.catch(error => {
  return fallbackPromise;
});

Promises are eager

Let's compare two code samples. How many times will we see Creating value logged?

const promisedValue = new Promise((resolve, reject) => {
  // Will this be logged once or not at all?
  console.log("Creating value");
  resolve(40 + 2);
});
const promisedValue = new Promise((resolve, reject) => {
  // Will this logged three times or once?
  console.log("Creating value");
  resolve(40 + 2);
});

promisedValue.then(console.log);
promisedValue.then(console.log);
promisedValue.then(console.log);

In both cases, we will see it logged only once. This is because promises are run once and created eagerly.

Listening to a promise using .then() neither affects nor starts that promise. It has no side-effect on the source promise.

Once a promise has been created, then you may wait to hear its result one time, fifteen times, or not at all, and the original promise will behave the same.

This may seem like a strange limitation, but it simplifies reasoning about promises as they work similar to values.

How values work

If we store a value in a variable, we can feel comfortable knowing that the reading of that variable has absolutely no effect on its underlying value.

const value = 40 + 2;

console.log(value);
console.log(value);
console.log(value);

The value of 42 will be logged three times, but if the logs were removed altogether, the variable’s value won’t be affected and will remain the same. The act of logging had no effect on the source value.

Promises work exactly the same.

We can use this to our advantage, by thinking about promises in the same way we think about values.

Reusing

If data is loaded from an API, we might use fetch().

const promisedResponse = fetch('https://swapi.dev/api/people/1/');

We can chain the response to decode the JSON body of the response.

const promisedData = fetch('https://swapi.dev/api/people/1/')
  .then(response => response.json());

What happens if we want to use this data again?

const promisedData = fetch('https://swapi.dev/api/people/1/')
  .then(response => {
    // How many times will we see this logged?
    console.log('decoding data');
    return response.json();
  });

promisedData.then(data => {
  // Use data
});

promisedData.then(data => {
  // Use data again
});

Here we will see ‘decoding data’ logged once. The fetch() call returns a Promise, which is chained using .then() where we decode the underlying JSON body by calling .json() on the response.

We can continue to think of these as eventual values. Once these values have been cast, they cannot change (technically we could mutate anything as JavaScript gives us free reign but we shouldn’t).

The response from fetch() is one eventual value. The decoded JSON is another eventual value, and actually has two Promises, one created by the .json() method, and another wrapping that which was created by .then().

const promisedResponse = fetch('https://swapi.dev/api/people/1/');
const promisedData = promisedResponse.then(response => {
  const promisedDataInner = response.json();
  return promisedDataInner;
});

Failure recovery

const fallbackData = {
  name: "Jane Doe",
  height: "168",
  mass: "67",
};

fetch('https://swapi.dev/api/people/1/')
  .then(res => res.data())
  .catch(() => fallbackData);

Async Await

The same applies if the code is rewritten to use async await. The underlying objects are still Promises.

The difference is that async requires a function, which means the code is run from scratch each time.

Here’s our API call written as a function using Promises:

const apiURL = new URL('https://swapi.dev/api/');

function fetchPerson(id) {
  return fetch(new URL(`people/${id}/`, apiURL))
    .then(response => {
      // How many times will we see this logged?
      console.log('decoding data');
      return response.json();
    });
}

function main() {
  fetchPerson('1'); // *Creates* promise
  fetchPerson('1'); // *Creates* another promise
  // We will see 'decoding data' logged twice,
  // as our function is run from scratch twice.
}

main();

And here’s that code rewritten to use async await.

const apiURL = new URL('https://swapi.dev/api/');

async function fetchPerson(id) {
  const response = await fetch(new URL(`people/${id}/`, apiURL));
  // How many times will we see this logged?
  console.log('decoding data');
  // Note: if we return a Promise, then there’s no need to await
  return response.json();
}

async function main() {
  await fetchPerson('1'); // *Creates* promise
  await fetchPerson('1'); // *Creates* another promise
  // We will see 'decoding data' logged twice,
  // as our function is run from scratch twice.
}

main();

However, if we use the result from our fetchPerson() function (which we be a Promise), and await that twice (or more) then since we are running the function only once we will see the ‘decoding data’ message logged only once too.

async function main() {
  const promise = fetchPerson('1');  // *Creates* promise

  await promise;
  await promise;
  // We will see 'decoding data' logged only once,
  // as our function is run only once.
}

main();

This is conceptually similar to calling .then() on the promise twice.

function main() {
  const promise = fetchPerson('1');

  promise.then(data => {
    // Do nothing
  });
  
  promise.then(data => {
    // Do nothing
  });

  // We will see 'decoding data' logged only once,
  // as our function is run only once.
}

main();

As we learned earlier, listening to a promise using .then() neither affects nor starts that promise — it has no side-effect on that promise. And await behaves the same — it also has no side-effect on the source promise. It simply waits for its eventual value.