Fetch Request

Happy Mother's Day! 💐

It's been another busy couple of weeks. I went down to visit the family and play Bill Baird's release show in San Antonio last weekend. This week we went to a farm dinner out in Paige and then last night I performed at one of those musical yoga events. A lot of community and a lot of art. So many blessings.

I'm picking up (sort of) where I left off in the last blog post. XHR has been wonderful for the transfer of data around the web, but a few years ago the Fetch API was introduced, which has largely replaced XHR usage.

In this blog post, I'm going to rebuild the app that was built in the last post. It has the same two buttons. If you click one, it fetches some data, and if you click the other it sends some data. I'll construct a basic fetch request, sending and receiving JSON, and handling errors. Once more, all credit goes to Academind for this YouTube video that taught me all of this.

Initial app setup

Our index.html file is almost exactly the same as last time. I've changed the text in the <title></title> tags from "XHR Request" to "Fetch Request", and instead of loading xhr.js, I'm loading fetch.js. Nothing else is going to change in this file in this blog post.

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=XHR Request, initial-scale=1.0">
    <title>Fetch Request</title>
    <script src="./fetch.js" defer></script>
  </head>
  <body>
    <section id="control-center">
      <button id="get-btn">GET data</button>
      <button id="post-btn">POST data</button>
    </section>
  </body>
</html>

The start of fetch.js is also going to be the same as the start of xhr.js:

// fetch.js
const getBtn = document.getElementById('get-btn');
const postBtn = document.getElementById('post-btn');

const getData = () => {};

const postData = () => {};

getBtn.addEventListener('click', getData);
postBtn.addEventListener('click', postData);

The API we're using

I'm still using the API over at reqres.in, which allows us to fetch and send dummy data via Fetch requests and get real responses back. You can use any test API endpoint service that you would like. This one is easy in that you don't need any authentication and you're not receiving or sending any real data.

Fetch a list of users (GET method)

So let's start adjusting the getData function to request a list of users from the API.

// ...in fetch.js
const getData = () => {
  // 1. Hit the endpoint to retrieve data response, this returns a Promise
  fetch('https://reqres.in/api/users')
    .then(response => {
      // 2. Specify we want JSON, not a ReadableStream; this returns another Promise
      return response.json();
    })
    .then(responseData => {
      console.log(responseData);
    });
};

In XHR, we had to construct an xhr object, and then construct a Promise. Fetch returns a Promise out of the box, so we simply call fetch() and pass it the url endpoint we want to fetch. We can then process the data using .then() methods. By default, Fetch's response is a ReadableStream, but in this case we want JSON. We call the .json() method on the response to convert the response to JSON. We add another .then() method, and console.log() out our data.

This is all we need so far to make a simple request. If we reload index.html in the browser, navigate to Dev Tools > Network, and then press the GET data button, we can see that the browser made the request for us.

On the Headers tab in Dev Tools > Network, we can see the headers for the request:

Headers for our network request.

And on the Preview tab in Dev Tools > Network, we can preview the data we fetched:

A list of users, received from our API GET request.

Here is our full fetch.js file at the moment:

// fetch.js
const getBtn = document.getElementById('get-btn');
const postBtn = document.getElementById('post-btn');

const getData = () => {
  // 1. Hit the endpoint to retrieve data response, this returns a Promise
  fetch('https://reqres.in/api/users')
    .then(response => {
      // 2. Specify we want JSON, not a ReadableStream; this returns another Promise
      return response.json();
    })
    .then(responseData => {
      console.log(responseData);
    });
};

const postData = () => {};

getBtn.addEventListener('click', getData);
postBtn.addEventListener('click', postData);

Slight refactor

So at this point I'll refactor the code we just wrote so that we can reuse everything we possibly can for both GET and POST requests.

I will define a general use sendFetchRequest function that accepts as its parameters method and url. This function will hit the url we specify with the method. I'll keep the JSON parsing step in this function as well, but pass that JSON onto the function that calls it through the Promise chain. In this case it's just getData(). getData() in turn will handle use that JSON data in whatever manner I see fit. Here is what that looks like:

// fetch.js
const getBtn = document.getElementById('get-btn');
const postBtn = document.getElementById('post-btn');

const sendFetchRequest = (method, url) => {
  return fetch(url, {
    method,
  })
    .then(response => {
      return response.json();
    });
}

const getData = () => {
  sendFetchRequest('GET', 'https://reqres.in/api/users')
    .then(responseData => {
      console.log(responseData);
    });
};

const postData = () => {};

getBtn.addEventListener('click', getData);
postBtn.addEventListener('click', postData);

I'm now calling sendFetchRequest() inside of getData() and passing it the GET method as well as the url endpoint. When it fulfills, I console.log() that response data. This has set me up nicely to build out my postData() function to send data.

Registering a user (POST method)

So in order to send data, I can use the sendFetchRequest() function I created in the last section, pass it the POST method, the endpoint I want to send the request to, and an object with the data that I want to send. It might look something like this:

// ...in fetch.js
const postData = () => {
  sendFetchRequest('POST', 'https://reqres.in/api/register', {
    email: 'test@test.com',
    password: 'tester',
  })
    .then(responseData => {
      console.log(responseData);
    });
};

I need to make a few more modifications to sendFetchRequest() for this to work. My helper function needs to accept the data object as its third parameter and convert that data object to a JSON string. Here's how I do that:

// ...in fetch.js
const sendFetchRequest = (method, url, data) => {
  return fetch(url, {
    method,
    body: JSON.stringify(data),
    headers: data ? {
      'Content-Type': 'application/json'
    } : {},
  })
    .then(response => {
      return response.json();
    });
}

I added a third parameter, data, which will be undefined for the getData() call, but will have a value for postData. I JSON.stringify() the data, and pass it to fetch as a body property in the options object. I also need to tell fetch to expect to receive JSON data, and I do this by setting a header of 'Content-Type': 'application/json'. Of course, I only need to do this if I have data. So I use the following ternary operator:

headers: data ? {
  'Content-Type': 'application/json'
} : {}

At this point, if I press the POST data button in the browser to send the request and check the console, I'll see that I get an error:

POST https://reqres.in/api/register 400 (Bad Request)
{error: 'Note: Only defined users succeed registration'}

Like with XHR, this is to be expected as I am not sending the correct values for the email and password props that the API endpoint wants. If I send it with their desired values...

// ...in fetch.js
const postData = () => {
  sendFetchRequest('POST', 'https://reqres.in/api/register', {
    email: 'eve.holt@reqres.in',
    password: 'pistol',
  })
    .then(responseData => {
      console.log(responseData);
    });
};

...then I get a success:

{id: 4, token: 'QpwL5tke4Pnpja7X4'}

Excellent. So the full fetch.js file contents at the moment, for the success:

// fetch.js
const getBtn = document.getElementById('get-btn');
const postBtn = document.getElementById('post-btn');

const sendFetchRequest = (method, url, data) => {
  return fetch(url, {
    method,
    body: JSON.stringify(data),
    headers: data ? {
      'Content-Type': 'application/json'
    } : {},
  })
    .then(response => {
      return response.json();
    });
}

const getData = () => {
  sendFetchRequest('GET', 'https://reqres.in/api/users')
    .then(responseData => {
      console.log(responseData);
    });
};

const postData = () => {
  sendFetchRequest('POST', 'https://reqres.in/api/register', {
    email: 'eve.holt@reqres.in',
    password: 'pistol',
  })
    .then(responseData => {
      console.log(responseData);
    });
};

getBtn.addEventListener('click', getData);
postBtn.addEventListener('click', postData);

And for the error:

// fetch.js
const getBtn = document.getElementById('get-btn');
const postBtn = document.getElementById('post-btn');

const sendFetchRequest = (method, url, data) => {
  return fetch(url, {
    method,
    body: JSON.stringify(data),
    headers: data ? {
      'Content-Type': 'application/json'
    } : {},
  })
    .then(response => {
      return response.json();
    });
}

const getData = () => {
  sendFetchRequest('GET', 'https://reqres.in/api/users')
    .then(responseData => {
      console.log(responseData);
    });
};

const postData = () => {
  sendFetchRequest('POST', 'https://reqres.in/api/register', {
    email: 'test@test.com',
    password: 'tester',
  })
    .then(responseData => {
      console.log(responseData);
    });
};

getBtn.addEventListener('click', getData);
postBtn.addEventListener('click', postData);

Handling errors

So we've seen a case where we get error messages back. How do we handle them in Fetch? I'll start by adding a .catch() method to the Promise chain within postData():

// ...in fetch.js
const postData = () => {
  sendFetchRequest('POST', 'https://reqres.in/api/register', {
    email: 'test@test.com',
    password: 'tester',
  })
    .then(responseData => {
      console.log(responseData);
    })
    .catch(err => {
      console.error(err);
    });
};

If I press the POST data button, I do indeed get an error:

{error: 'Note: Only defined users succeed registration'}

But this isn't quite right. I need to update my helper function to actually throw the error, along with the details I get back from the API call. As with XHR, I can do so by checking the status code that fetch() returns, then throwing an error and rejecting the Promise:

// ...in fetch.js
const sendFetchRequest = (method, url, data) => {
  return fetch(url, {
    method,
    body: JSON.stringify(data),
    headers: data ? {
      'Content-Type': 'application/json'
    } : {},
  })
    .then(response => {
      if (response.status >= 400) {
        return response.json()
          .then(errorResponseData => {
            const error = new Error('Something went wrong!');
            error.data = errorResponseData;
            throw error;
          });
      }
      return response.json();
    });
}

So I've thrown a new Error with a custom message, "Something went wrong!", and appended the response I got back from the API as a data property on that Error. Back over in our .catch() block, I can read both of these like so:

const postData = () => {
  sendFetchRequest('POST', 'https://reqres.in/api/register', {
    email: 'test@test.com',
    password: 'tester',
  })
    .then(responseData => {
      console.log(responseData);
    })
    .catch(err => {
      console.error(err, err.data);
    })
};

So in this way, I am handling any errors that come back from the API by rejecting them and passing them onto my Promise .catch() block.

Here is my fully finished fetch.js file:

// fetch.js
const getBtn = document.getElementById('get-btn');
const postBtn = document.getElementById('post-btn');

const sendFetchRequest = (method, url, data) => {
  return fetch(url, {
    method,
    body: JSON.stringify(data),
    headers: data ? {
      'Content-Type': 'application/json'
    } : {},
  })
    .then(response => {
      if (response.status >= 400) {
        return response.json()
          .then(errorResponseData => {
            const error = new Error('Something went wrong!');
            error.data = errorResponseData;
            throw error;
          });
      }
      return response.json();
    });
}

const getData = () => {
  sendFetchRequest('GET', 'https://reqres.in/api/users')
    .then(responseData => {
      console.log(responseData);
    });
};

const postData = () => {
  sendFetchRequest('POST', 'https://reqres.in/api/register', {
    // error case:
    email: 'test@test.com',
    password: 'tester',

    // success case:
    // email: 'eve.holt@reqres.in',
    // password: 'pistol',
  })
    .then(responseData => {
      console.log(responseData);
    })
    .catch(err => {
      console.error(err, err.data);
    })
};

getBtn.addEventListener('click', getData);
postBtn.addEventListener('click', postData);

Summary

Fetch is largely seen as a modern improvement on the older XHR request pattern. Where in XHR you had to explicitly create your own Promise, create an xhr object, and specify values for xhr methods like .open(), .responseType, .setRequestHeader(), .onload, .onerror, and .send(), Fetch compartmentalizes all of that into a single fetch() function call, which by default returns a Promise.

However, it's still not a totally seamless API. Error handling is often seen as complicated and repetitive. Similar to XHR, Fetch will only throw an error itself if there is a network failure, you pass a bad URL, or something else goes wrong at the system level. It doesn't consider response status codes greater than 400 to be errors, so you have to manually check for those yourself, then throw the error, and then specify your .catch() block to handle them.

Our current archecture reuses a lot of code to make the requests, but handling errors is still attached to each instance of a function call. I dislike that because it still feels like a lot of boilerplate. If we had a dozen buttons that called different endpoints, I wouldn't want to have to repeat the .catch() logic for each one. Oh well. This is still a pretty cool API, and at this point has very good support across the browser and JavaScript ecosystem.