The Javascript Promise - A functional walkthrough

I came across a tutorial recently that made liberal use of Javascript Promises. I was having a hard time understanding them, so I punched the wall a few times and then took a deep breath and watched some youtube videos. Here's what I learned.

A brief disclaimer before we begin: I am learning this as I write. If you're looking for an in-depth explanation of how JS Promises work under-the-hood, you won't get your fix here. There are lots of resources online that'll provide more depth... I found this blog post to be super comprehensive/helpful. In this post, I'll skim the surface of what Callbacks/Promises are, and then walk-through building a little web app to demonstrate how they might function within your code.

Without further ado...

Part 1: To (callback) hell and back again

Here's how the MDN defines a Javascript Promise:

"A Promise is a proxy for a value not necessarily known when the promise is created. It allows you to associate handlers with an asynchronous action's eventual success value or failure reason. This lets asynchronous methods return values like synchronous methods: instead of immediately returning the final value, the asynchronous method returns a promise to supply the value at some point in the future."

Nah i don't get it.

But, when I see the word "asynchronous" in a Javascript context I automatically think of Callback Functions. There are some layers of complexity to callback functions, but most simply put, a "callback" is just the javascript convention/practice of passing a function into another function as an argument. So...here's a simple / common example:

$("#button").click(function() {
  alert("Submitted!");
});

We're passing an anonymous function to the click() function as an argument, and that anonymous function is then invoked when the click() function is (in this case when an html element with id = "button" is clicked).

Often times, callback functions are associated with Javascript functions that make API requests, query a database... that type of asynchronous stuff. Soooo, say we have a search form that hits an external API... we'll receive some data back, at which point we'll want to do something with that data. Consider the following pseudocoded example, where we try to access a movie's plot summary by querying an API using the movie title:

// Through a form submission, query an external API for a movie plot summary (ex. user searches "Inception")
  // If you get an error back
    // Handle that error (ex. alert user "no such movie exists in the database, check your spelling")
  // If you successfully get data back
    // Do something with that data (ex. append the plot summary to the div underneath the search bar)

So in the above pseudocode, when a user searches a movie title (we call .submit() on the form), the .submit() function would then invoke a callback function to handle the API response, which in turn would either return an error (i.e. the movie doesn't exist) or do something with the data.

Make sense kinda? Okay now imagine the same movie database API example, but this time let's up the complexity. This is totally arbitrary, but for demonstration's sake let's say that now, for whatever reason, this API requires that you have a Movie ID in order to get a plot summary for a specified movie. Well, as an end-user, we have no idea what Movie ID is associated with the movie we want a plot summary for. We just know the title of the movie we want to search. So what do we do?

Welp, we could use callbacks. More specifically, we could nest callbacks. I'll pseudocode it out:

// Through a form submission, search a movie title that you want to get a plot summary for <-------> (CALLBACK / API QUERY #1)
  // If you get an error back
    // Handle that error (ex. alert user "no such movie exists in the database, check your spelling")
  // If you successfully get data back
    // Extract the MOVIE ID from the resulting data
  // Re-query the API, this time using the MOVIE ID you extracted instead of the movie title  <--> (CALLBACK / API QUERY #2)
    // If you get an error back
      // Handle that error
    // If you successfully get data back
      // Do something with that data (ex. extract plot summary from the data + append it to the div underneath the search bar)

Here's the issue (well, AN issue) with that: The above nested callback structure is only one-level deep, but imagine if we had to nest a callback within a callback within a callback within a callback. Idk how many layers that is, but I know the code would look and feel totally unruly. This code pattern is affectionately known as "CALLBACK HELL" (see link for a hellish example). It's not totally analogous to nesting for-loops, but aesthetically and conceptually, in my opinion, it is equally if not more confusing.

Enter: Promises, which, as far as I can tell, can totally clean up your code / replace nested callbacks.

You can write your own Promises from scratch (here ya go), which is probably important/beneficial in understanding how they work, but I'm NOT going to explain that here. What I'm going to do is walk through how we might use Promises by building them into a tiny lil web page.

A couple quick/important things to note before I start building:

  1. You can call methods on Promises, two of the most common being .then() and .catch(). Both of these functions actually RETURN promises (i.e. aPromiseObject.then() returns a promise), meaning that we can CHAIN Promise statements. Which as you will see shortly, is a great thing.
  2. I'll be using the Fetch API, which is a Javascript tool used to make network requests / fetch resources. Basically, it takes one argument (the path to the resource you want), and returns... you guessed it, a Promise.

PART 2: Building Promises into a web app

I'll stick with the movie database example... I'm goint to build a purely functional (i.e. i'm ignoring design / making it pretty) web app that:

  1. Prompts a user to search for a movie title to get its plot summary back
  2. Takes that user input through a form
  3. Hits a movie database API
  4. Appends the movie's plot summary to the page (and the movie title + a poster image, just cuz)

Here we go.

Let's start out by writing a basic HTML skeleton

<!-- filename: index.html -->

<!DOCTYPE html>
<html lang="en">

<head>
  <title>Plot-Finder</title>

</head>

<body>
  <div id="input-form">
    <form id="search-bar">
      Search for a movie:
      <input type="text" id="searched-movie" placeholder="Search for a movie">
      <input type="submit" value="Submit">
    </form>
  </div>
  <div id="main">
  </div>

  <script src="promiseBlog.js"></script>
</body>

</html>

Very basic. We have two <div>'s. The first (id="input-form") will contain the text "Search for a movie" and a <form> element with an id of "search-form". The second div (id="main") is empty for now. That's where we'll eventually put the movie title + plot summary + image. Also notice I'm linking this index.html file to a currently-non-existent promiseBlog.js file in the <script> tag at the bottom. We'll creat that file next.

Create the JS file and declare a few variables

// filename: promiseBlog.js

const apiKey = "GetYourOwnLol"; // you'll need to request your own API key
const imageBaseURL = "https://image.tmdb.org/t/p/w500";
const inputForm = document.getElementById("search-bar");

So in promiseBlog.js we've got three variables. The apiKey is essential, as that is what will allow us to request/access the data we need from the API. If you're following along, you can create an account and request your own API key here, but heads up it might take a few days for them to issue you a key. The inputForm variable contains our form element from the HTML. I just created it in anticipation of our form-submission later on, but it's not essential. The imageBaseURL string variable will be used to extract an image for the movie later on. I only know that I need this bc I've worked with this API before.

Establish a couple helper functions

Based on how I know this API works (like I said I have used it before, but you can find all of this info by reading the API docs), I know a couple things:

  1. In order to search for a movie by title, we need the title input string to have a specific format. If it's a single word (i.e. "Hereditary"), we can leave the string as-is. BUT, if the movie title is multiple-words as a majority are, we need to format the string like so: "word1+word2+word3" (with plus-signs in between each word in the title).
  2. Once we have access to the movie object from the database (which will be returned as part of the response to our API request), we can extract tons of information about the movie. We'll want to get the movie title, overview (plot), and image url (the path to the associated image they have stored for the movie).

Knowing these two things, I wrote two "helper" functions:

const formatSearchTerm = () => {
  let movie = document.getElementById("searched-movie").value;
  let wordArr = movie.split(" ");
  let returnString = "";
  for (let i = 0; i < wordArr.length; i++) {
    returnString += wordArr[i] + "+";
  }
  return returnString.slice(0, -1);
};

const formatHTML = (title, overview, imagePath) => {
  return `<strong><p>${title}</p></strong><p>${overview}</p><img src=${imagePath}></img>`;
};

The purpose of these functions is, as their names suggest, to format stuff. formatSearchTerm() will take the movie title that the user searches, split the words into an array, then iterate through each word and add a "+" to each one and return a properly-formatted string. formatHTML() is going to take all of the data we extract from the API (the title, overview and image path) and format it within an HTML skeleton, which will eventually be added to our current HTML. Simple enough. Both of these functions could be written later on as part of our our main function logic, but I thought it'd be simpler/cleaner to separate them out.

Handle user input

Now, let's start to write the code to handle our user's input:

inputForm.addEventListener("submit", function(e) {
  e.preventDefault();
  let searchedMovie = formatSearchTerm();
  let movieDbURL = `https://api.themoviedb.org/3/search/movie?api_key=${apiKey}&query=${searchedMovie}`;
});

Here we add an event listener to our inputForm, i.e. we say "when the user submits a movie title, execute the following code." Right now, that code just consists of three lines:

  • e.preventDefault() which prevents the form from actually submitting (bc instead of submitting, we actually want to go hit the API... we'll see this in action in the next step).
  • let searchedMovie = formatSearchTerm() which utilizes our formatSearchTerm() function, assigning the return value (ex. "finding+nemo") to a variable.
  • let movieDbURL = "https://api.themoviedb.org/3/search/movie?api_key=${apiKey}&query=${searchedMovie}" which takes that above variable, inserts it into API url, and then assigns that full string to its own variable.

I know we haven't done anything weith Promises yet. Let's do that now.

Hit the API, and handle the response (or: Using Promises, the actual point of this blog post)

This is where we'll utilize the fetch() function I mentioned earlier, which as a reminder will make a network request to the API and return a Promise containing a Response object (I recommend reading more about that in the fetch() docs). I'll build the next few pieces of logic out step-by-step, and include some screenshots to help us understand what's happening. Let's start out by simply making the API request and logging the response to the console:

inputForm.addEventListener("submit", function(e) {
  e.preventDefault();
  let searchedMovie = formatSearchTerm();
  let movieDbURL = `https://api.themoviedb.org/3/search/movie?api_key=${apiKey}&query=${searchedMovie}`;

  fetch(movieDbURL)
    .then(function(res) {
      console.log(res);
    });
});

So what'd we do. We fed the movieDbURL to the fetch() method, which returns a promise containing a Response object, which we eventually want to extract some data from. Remember way back when I said we could use .then() and .catch() on Promises? .then() if the promise is fulfilled, .catch() if there's an error. So above we're saying, if the promise is fulfilled with a valid response, then print the response to the console. We'll refactor and add a .catch() in a moment... first, here's a screenshot of where we're at right now: image

Kk first things first: Above, you can see we're printing the body of an HTTP response. Not really what we want to be working with. What we'd really like is to parse that Response into a readable JSON format, which we can achieve by calling .json() on the response body (i.e. console.log(res.json());... more on this in a bit).

Second, we can refactor the callback we pass to .then() (i.e. function(res) {...}) using arrow syntax. You'll start to see how chaining a sequence of .then() and .catch() statements really cleans things up.

Third, we'll add a .catch() at the bottom to handle any errors. Typically you would work with an actual Error object here, but I'm just going to have an alert pop up that tells the user the movie doesn't exist in the database. So here's the updated + refactored code, with an updated screenshot below:

inputForm.addEventListener("submit", function(e) {
  e.preventDefault();
  let searchedMovie = formatSearchTerm();
  let movieDbURL = `https://api.themoviedb.org/3/search/movie?api_key=${apiKey}&query=${searchedMovie}`;

  fetch(movieDbURL)
    .then(res => console.log(res.json()))
    .catch(err => alert("no such movie exists in our db"));
});

image

Ahhh, there's our Promise. Plus, now you can see that within that Promise object we've got a "results" array that has REAL DATA in it. Which we can start to work with. Wild.

But here's the crazy thing... what did we print to the console? We printed res.json() and you can clearly see in the console that a Promise object was printed, not the actual JSON data. You guessed it, the .json() method ALSO RETURNS A PROMISE. Insanity. And since that returns a promise, we can continue our chain of .then()'s.

So let's first remove the console.log() statement (since we want to actually invoke res.json() vs. just printing it).

  fetch(movieDbURL)
    .then(res => res.json()) // removed "console.log()"
    .catch(err => alert("no such movie exists in our db"));
});

Then, with the resulting Promise, if it's fulfilled with no errors, let's just print the result for now.

  fetch(movieDbURL)
    .then(res => res.json())
    .then(json => console.log(json)) // added this line
    .catch(err => alert("no such movie exists in our db"));
});

Aaaaaaand here's the screenshot:

image

Looks pretty similar to the last screenshot, but this time you can see we're printing actual JSON data, as opposed to a Promise object containing JSON data. So NOW, we're ready to work with that data.

If you examine the JSON above you can see we have everything we need. We can access the movie object by calling json.results[0], and within that object we can get the actual movie title ('original_title'), the plot summary ('overview'), and the image path ('backdrop_path'). I'll paste the entire updated JS file below... Direct your attention down to the fetch() part at the bottom, that's the only place I made changes:

const apiKey = "9f7cef3d5fb8d4275c152db38844568c";
const imageBaseURL = "https://image.tmdb.org/t/p/w500";
const inputForm = document.getElementById("search-bar");

const formatSearchTerm = () => {
  let movie = document.getElementById("searched-movie").value;
  let wordArr = movie.split(" ");
  let returnString = "";
  for (let i = 0; i < wordArr.length; i++) {
    returnString += wordArr[i] + "+";
  }
  return returnString.slice(0, -1);
};

const formatHTML = (title, overview, imagePath) => {
  return `<strong><p>${title}</p></strong><p>${overview}</p><img src=${imagePath}></img>`;
};

inputForm.addEventListener("submit", function(e) {
  e.preventDefault();
  let searchedMovie = formatSearchTerm();
  let movieDbURL = `https://api.themoviedb.org/3/search/movie?api_key=${apiKey}&query=${searchedMovie}`;

  fetch(movieDbURL)
    .then(res => res.json())
    .then(json => {
      // extract the movie object
      targetObject = json.results[0]; 
      // format the data we need and assign it to our main HTML
      document.getElementById("main").innerHTML = formatHTML(
        targetObject.original_title,
        targetObject.overview,
        imageBaseURL + targetObject.backdrop_path
      );
    })
    .catch(err => alert("no such movie exists in our db"));
});

Now, we extract the object we want to work with (json.results[0]) and assign it to a variable targetObject. Then, we use the formatHTML function we wrote to format the three pieces of data we extract, and assign that to the innerHTML of our previously empty <div id="main"></div>. What do we get? Here it is:

image

To sum up what we've done:

  • Instead of using a series of nested callback functions + if/then statements + indentions indentions indentions... we simply chain Promises (and their associated .then() and .catch() methods) to produce a sequence of clean, concise code.
  • The sequence logic is as follows... Once the user submits a movie title via the search-form, 1) We attempt to fetch the movie data from an API (fetch()). 2) If that request is fulfilled, we attempt to convert it into JSON format so we can work with it. 3) If that function is fulfilled successfully, we attempt to extract the data we need, and then format that data into the appropriate HTML code. 4) If at any point in the sequence we get an error (or an unfulfilled promise), alert the user.

image

This is a very simple use-case for Javascript Promises. If you'd like to see a slightly more complex example and you dig instructional videos, check out this guy's youtube series on Promises. He starts out by explaining callbacks vs. promises, and then builds a program that requires multiple asynchronous requests to two different API's. It's still fairly simple to follow, but he adds one additional layer of complexity to further demonstrate the power of Promises.

Thanks for reading + love you guys,

noah

view raw promise.md hosted with ❤ by GitHub

Comments

Popular posts from this blog

Starting something new