How to create a custom fetch API from XMLHttpRequest
What is your worst nightmare?
That sounded dark, but it’s not a rhetorical question. I really want to know because I am about to tell you mine. Along the way, we will learn some things like how the fetch API works and also how function constructors work.
Sorry I digress, back to my worst nightmare. If you had asked me that question last week it would be the below list in no particular order:
- Writing Pre-ES6 syntax
- No fetch API
- No Transpiler (Babel/Typescript)
- Uncle Bob said that I’m a disappointment (Kidding)
If your list matches mine then I have to say that you are a very weird person. As luck would have it I was called to work on a project that brought to life my nightmare list (excluding the last one). I was to add a new feature to the application. It was a legacy codebase that used purely pre-es6 syntax and XMLHttpRequest (the horror) for its AJAX requests.
So in a bid to make the experience palatable, I decided to create a function that abstracts all the AJAX requests I would be making and expose APIs that mimics the new fetch API (well not really). This is also after I watched the Javascript: The new hard parts video on frontend masters where an amazing explanation of how the fetch API works under the hood was given. Let’s begin.
First, I had to look up how XMLHttpRequest works. Then I started writing the function. My first iteration looked like this:
"use strict";function fetch() {
var url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};var xhr = new XMLHttpRequest();
var onFufillment = [];
var onError = [];
var onCompletion = [];
var method = "GET" || options.method;
xhr.onreadystatechange = function () {
var _data = this;
if (this.readyState == 4 && this.status == 200) {
// Action to be performed when the document is read;
onFufillment.forEach(function (callback) {
callback(_data);
}); onCompletion.forEach(function (callback) {
callback(_data);
});
} else if (this.readyState == 4 && this.status !== 200) {
onError.forEach(function (callback) {
callback(_data);
});
onCompletion.forEach(function (callback) {
callback(_data);
});
}
};
xhr.open(method, url, true);
xhr.send();return {
then: function then(fufillmentFunction) {
onFufillment.push(fufillmentFunction);
},
catch: function _catch(errorFunction) {
onError.push(errorFunction);
},
finally: function _finally(completionFunction) {
onCompletion.push(completionFunction);
}
};
}
Let me work through what the function does:
- We are checking if the
url
argument is passed into the function. Defaulting to an empty string if nothing is passed - We are also doing the same thing for the
options
argument. Defaulting to an empty object if nothing is passed - Then we create a new instance of the XMLHttpRequest
- We create 4 variables
onFufillment, onError, onCompletion and method
onFufillment
is an array that stores all the functions passed into thethen
methodonError
is an array that stores all the functions passed into thecatch
methodonCompletion
is an array that stores all the functions passed into thefinally
methodmethod
is used to store the HTTP method that will be used, it defaults toGET
- We then pass a function into the
onreadystatechange
method ofxhr
which will be called when the state of the request changes - In the function, we save
this
into a_data
variable so that it can be passed into the forEach functions without losing its context (I knowthis
is annoying) - We then check if the request is completed (
readyState == 4
) and if the request is successful, then we loop throughonFufillment and onCompletion
arrays, calling each function and passing_data
into it - If the request fails we do the same thing with the
onCompletion and onError
arrays - Then we send off the request with the passed in parameters
- After that, we return an object containing three functions, then.
catch and finally
which have the same names as the fetch API. catch
pushes the function that is passed as an argument into theonError
arraythen
does the same thing with theonFufillment
arrayfinally
does the same with theonCompletion
array
The usage of this API will look like this:
var futureData = fetch('https://jsonplaceholder.typicode.com/todos/2');futureData.then(function(data){
console.log(data)
})futureData.finally(function(response){
console.log(response);
});futureData.catch(function(error){
console.log(error);
})
It works!!! But not nearly as the real fetch implementation. Can we do better than this? Of course, we can. We can still add more features to the function. We could make it chainable, that is, we can give it the ability to chain methods together.
On the second iteration, this is how it looks:
"use strict";
function fetch() {
var url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
var xhr = new XMLHttpRequest();
var onFufillment = [];
var onError = [];
var onCompletion = [];
var method = "GET" || options.method;
xhr.onreadystatechange = function () {
var _data = this;
if (this.readyState == 4 && this.status == 200) {
// Action to be performed when the document is read;
onFufillment.forEach(function (callback) {
callback(_data);
});
onCompletion.forEach(function (callback) {
callback(_data);
});
} else if (this.readyState == 4 && this.status !== 200) {
onError.forEach(function (callback) {
callback(_data);
});
onCompletion.forEach(function (callback) {
callback(_data);
});
}
};
xhr.open(method, url, true);
xhr.send();
return {
then: function then(fufillmentFunction) {
onFufillment.push(fufillmentFunction);
return this;
},
catch: function _catch(errorFunction) {
onError.push(errorFunction);
return this;
},
finally: function _finally(completionFunction) {
onCompletion.push(completionFunction);
return this;
}
};
}
The usage of the API will look like this:
var futureData = fetch('https://jsonplaceholder.typicode.com/todos/2');futureData.then(function(data){
console.log(data)
}).then(function(response){
console.log(response);
}).catch(function(error){
console.log(error);
});
What did it do? The only difference in the second iteration was in the then, catch and finally
where I just returned this
which means each function returns itself basically enabling it to be chained (partially).
Better right? But can we do better than this? Of course, we can. The returned object can be put in the function's prototype so that we can save memory in a situation where the function is used multiple times.
This is how it looks on the third iteration:
"use strict";
function fetch() {
var fetchMethod = Object.create(fetch.prototype);
var url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
var xhr = new XMLHttpRequest();
fetchMethod.onFufillment = [];
fetchMethod.onError = [];
fetchMethod.onCompletion = [];
var method = "GET" || options.method;
xhr.onreadystatechange = function () {
var _data = this;
if (this.readyState == 4 && this.status == 200) {
// Action to be performed when the document is read;
fetchMethod.onFufillment.forEach(function (callback) {
callback(_data);
});
fetchMethod.onCompletion.forEach(function (callback) {
callback(_data);
});
} else if (this.readyState == 4 && this.status !== 200) {
fetchMethod.onError.forEach(function (callback) {
callback(_data);
});
fetchMethod.onCompletion.forEach(function (callback) {
callback(_data);
});
}
};
xhr.open(method, url, true);
xhr.send();
return fetchMethod;
};fetch.prototype.then = function(fufillmentFunction) {
this.onFufillment.push(fufillmentFunction);
return this;
};
fetch.prototype.catch = function(errorFunction) {
this.onError.push(errorFunction);
return this;
};fetch.prototype.finally = function(completionFunction) {
this.onCompletion.push(completionFunction);
return this;
};
So this version basically moves the returned function into the fetch’s prototype. If you don’t understand the statement then I recommend checking out this article about Javascript’s prototype (Thanks, Tyler McGinnis).
Is this an improvement? Yes!!! Can we do better? Of course, we can. We can use the new
keyword to our advantage here and remove the explicit return statement.
The next iteration will look like this:
"use strict";
function Fetch() {
var url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
var xhr = new XMLHttpRequest();
this.onFufillment = [];
this.onError = [];
this.onCompletion = [];
var method = "GET" || options.method;
var internalFetchContext = this;
xhr.onreadystatechange = function () {
var _data = this;
if (this.readyState == 4 && this.status == 200) {
// Action to be performed when the document is read;
internalFetchContext.onFufillment.forEach(function (callback) {
callback(_data);
});
internalFetchContext.onCompletion.forEach(function (callback) {
callback(_data);
});
} else if (this.readyState == 4 && this.status !== 200) {
internalFetchContext.onError.forEach(function (callback) {
callback(_data);
});
internalFetchContext.onCompletion.forEach(function (callback) {
callback(_data);
});
}
};
xhr.open(method, url, true);
xhr.send();
};Fetch.prototype.then = function(fufillmentFunction) {
this.onFufillment.push(fufillmentFunction);
return this;
};
Fetch.prototype.catch = function(errorFunction) {
this.onError.push(errorFunction);
return this;
};Fetch.prototype.finally = function(completionFunction) {
this.onCompletion.push(completionFunction);
return this;
};
Let me explain the changes:
- Changed the name of the function from fetch to Fetch, it’s just a convention when using the
new
keyword - Since I am using the
new
keyword I can then save the various arrays created to thethis
context. - Because the function passed into
onreadystatechange
has its own context I had to save the originalthis
into its own variable to enable me to call it in the function (I know,this
can be annoying) - Converted the prototype functions to the new function name.
The usage will look like this:
var futureData = new Fetch('https://jsonplaceholder.typicode.com/todos/1');futureData.then(function(data){
console.log(data)
}).then(function(response){
console.log(response);
}).catch(function(error){
console.log(error);
})
Voilà! That was really fun. But can we do better? Of course, we can.
But I will leave that to you. I would love to see your own implementation of the API in the comments below.
If you liked the article (and even if you didn’t), I would appreciate a clap (or 50) from you. Thank you.