XHR Request

Austin Psych Fest was this past weekend. This festival changed its name to Levitation several years ago, but then last year returned as its own, separate festival. It feels like the unofficial kickoff of Summer in Austin. I had the great privilege to play with Thor & Friends on Saturday, the second day of the festival. Here's a picture of me in the pink and yellow cape that my mom made for me last year and the rest of my pink and yellow outfit that I've been performing in for the last year and a half or so:

Thor & Friends on stage at Austin Psych Fest 2024.

The wizard hat I'm wearing is a non-standard item in this outfit. Regardless, as Summer comes on, thoughts wander to travel and vacation. We don't have any trips lined up just yet. But it also has me thinking about how data travels around the internet. One of the key technologies that makes this happen is XMLHttpRequest, often abbreviated and referred to as XHR. Despite its name, XHR can be used to transport several data types across the internet, including JSON, HTML, Blobs, and ArrayBuffers.

In this blog post, I'm going to build a little app with two buttons. If you click one, it fetches some data, and if you click the other it sends some data. I'll dig into constructing your basic xhr object, sending and receiving JSON, and handling errors. All credit goes to Academind for this YouTube video that taught me all of this.

Initial app setup

We'll start with a basic HTML document with two buttons:

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=XHR Request, initial-scale=1.0">
    <title>XHR Request</title>
    <script src="./xhr.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>

This document loads in an xhr.js file, which is where I'll be focusing my efforts for the rest of this blog post. Here's the very basic start of that file:

// xhr.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 using the API over at reqres.in, which allows us to fetch and send dummy data via XHR requests and get real responses back. It's just an easy, low lift solution. There are lots of these kinds of test API endpoint services out there.

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 xhr.js
const getData = () => {
  // 1. create new xhr object
  const xhr = new XMLHttpRequest();
  // 2. prepare xhr object to make a request. take two arguments:
  //    2.1. the method
  //    2.2. the endpoint URL
  xhr.open('GET', 'https://reqres.in/api/users');
  // 3. send the request
  xhr.send();
};

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.

Now, in order to use that data, we need to set up a listener on the xhr.onload event:

// ...in xhr.js
xhr.onload = () => {
  const data = JSON.parse(xhr.response);
}

The response comes back as a JSON string, which we then parse into a JavaScript object using JSON.parse().

Another route we can go here is to specify that 'json' is the response type that we want from the XHR object:

// ...in xhr.js
xhr.responseType = 'json';

xhr.onload = () => {
  const data = xhr.response;
}

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

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

const getData = () => {
  // 1. create new xhr object
  const xhr = new XMLHttpRequest();
  // 2. prepare xhr object to make a request. take two arguments:
  //    2.1. the method
  //    2.2. the endpoint URL
  xhr.open('GET', 'https://reqres.in/api/users');

  // 3. Specify JSON as the response type
  xhr.responseType = 'json';

  // 4. set up a listener on the xhr objects' onload event
  xhr.onload = () => {
    const data = xhr.response;
    console.log(data);
  }

  // 5. send the request
  xhr.send();
};

const postData = () => {};

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

Slight refactor

So we've fetched some data, but much of the same code also gets used for sending data via XHR. Before diving into the POST method function, we'll refactor some of what we wrote into a sendHttpRequest function that can get used with both the GET and POST methods.

We will define a general use sendHttpRequest function that accepts as its parameters method and url. Next thing is to return a Promise that handles the XHR request. This allows us to work asynchronously, fetching our data, and then doing some more work when that request either fulfills or rejects. Here is what that looks like:

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

const sendHttpRequest = (method, url) => {
  const promise = new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open(method, url);

    xhr.responseType = 'json';

    xhr.onload = () => {
      resolve(xhr.response);
    };

    xhr.send();
  });
  return promise;
}

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

const postData = () => {};

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

We're now calling sendHttpRequest() inside of getData() and passing it the GET method as well as the url endpoint. When it fulfills, we console.log() that response data. Nothing major. But with this refactor, we can now build out our postData() function to send data.

Registering a user (POST method)

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

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

There are a few problems with this. First, sendHttpRequest() isn't currently set up to accept the object as its third parameter. Second, we need to convert our JavaScript object to a JSON string. We can address these issues by modifying sendHttpRequest() like so:

// ...in xhr.js
const sendHttpRequest = (method, url, data) => {
  const promise = new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open(method, url);

    xhr.responseType = 'json';3

    xhr.onload = () => {
      resolve(xhr.response);
    };

    xhr.send(JSON.stringify(data));
  });
  return promise;
}

First, we added a third parameter, data, which will be undefined for the getData() call, but will have a value for postData. Second, we change xhr.send() to xhr.send(JSON.stringify(data)).

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

xhr.js:15
POST https://reqres.in/api/register 400 (Bad Request)
{error: 'Missing email or username'}

The error says "Missing email or username". However, if we navigate to Network > Payload, we see that we indeed sent an object with email and password props:

{
  email: "test@test.com",
  password: "tester"
}

So what gives? Well, it's failing because we haven't sent all of the correct headers to the API endpoint. We need to signal to the API that we are appending JSON data to our request, and we do so with the following:

xhr.setRequestHeader('Content-Type', 'application/json');

But we need to do so only when we have data:

// ...in xhr.js
if (data) {
  xhr.setRequestHeader('Content-Type', 'application/json');
}

Now if we send the POST request again, we see a different error:

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

This is to be expected as we are not sending the correct values for the email and password props that the API endpoint wants. If we send it with their desired values...

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

...then we get a success:

{id: 4, token: 'QpwL5tke4Pnpja7X4'}

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

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

const sendHttpRequest = (method, url, data) => {
  const promise = new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open(method, url);

    xhr.responseType = 'json';

    if (data) {
      xhr.setRequestHeader('Content-Type', 'application/json');
    }

    xhr.onload = () => {
      resolve(xhr.response);
    };

    xhr.send(JSON.stringify(data));
  });
  return promise;
}

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

const postData = () => {
  sendHttpRequest('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:

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

const sendHttpRequest = (method, url, data) => {
  const promise = new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open(method, url);

    xhr.responseType = 'json';

    if (data) {
      xhr.setRequestHeader('Content-Type', 'application/json');
    }

    xhr.onload = () => {
      resolve(xhr.response);
    };

    xhr.send(JSON.stringify(data));
  });
  return promise;
}

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

const postData = () => {
  sendHttpRequest('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 XHR? Well we start by using the xhr.onerror method:

// ...in xhr.js
xhr.onerror = () => {
  reject('Something went wrong!')
}

We can then update the functions that call the Promise with a .catch() block to accept this error. For example, our postData() function:

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

If, say, we omit the password in our request, we should see this error reject. But if we try to run the code, we get the following:

{error: 'Missing password'}

We do get an error from the API response, but it's not the error we specified. That's because technically this response did succeed. We sent some data and received something back.

So let's further update our XHR object to catch any responses with unsuccessful codes, which would be 400 or greater:

// ...in xhr.js
xhr.onload = () => {
  if (xhr.status >= 400) {
    reject(xhr.response);
  } else {
    resolve(xhr.response);
  }
};

xhr.onerror = () => {
  reject('Something went wrong!')
}

So in this way, we are handling any errors that come back to us from the API by rejecting them and passing them onto our Promise .catch() block, as well as anything that goes wrong at the application level.

Here is our fully finished xhr.js file:

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

const sendHttpRequest = (method, url, data) => {
  const promise = new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open(method, url);

    xhr.responseType = 'json';

    if (data) {
      xhr.setRequestHeader('Content-Type', 'application/json');
    }

    xhr.onload = () => {
      if (xhr.status >= 400) {
        reject(xhr.response);
      } else {
        resolve(xhr.response);
      }
    };

    xhr.onerror = () => {
      reject('Something went wrong!')
    }

    xhr.send(JSON.stringify(data));
  });
  return promise;
}

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

const postData = () => {
  sendHttpRequest('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);
    })
};

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

Summary

XHR is a pretty cool API for sending and retrieving data that has very broad support in the JavaScript and web ecosystem. There are very straightforward ways to use different request methods (POST, PUT, etc.), to attach headers, and to listen for success and error responses in the request lifecycle.

Using it as is, there's a bit of boilerplate that needs to be added. We saw how we had to move functionality in a Promise to make XHR useful for both GET and POST requests, how we added exceptions for if we had an optional data object we were appending to the request, and to listen for errors in multiple parts of the request lifecycle. This is maybe not as straightforward as it possibly can be. Things like the Fetch API or Axios give us the ability to use Promises out of the box and have somewhat cleaner error handling, but it's still super valuable to know how the XHR API works.