In my last two articles, “Using Ajax and REST APIs in .NET 5” and "Build a CRUD Page Using JavaScript and the XMLHttpRequest Object", I introduced you to using the XMLHttpRequest object to make Web API calls to a .NET 5 Web server. Whether you use jQuery, Angular, React, Vue, or almost any other JavaScript framework to make Web API calls, most likely, they use the XMLHttpRequest object under the hood. The XMLHttpRequest object has been around as long as JavaScript has been making Web API calls. This is the reason it still uses callbacks instead of Promises, which are a much better method of asynchronous programming.

In this article, you'll learn to use the Fetch API, which is a promise-based wrapper around the XMLHttpRequest object. As you'll see, the Fetch API makes using the XMLHttpRequest object easier to use in some ways but does have some drawbacks where error handling is concerned. To make working with the Fetch API a little easier, a set of IIFEs (closures) are created in this article. Using a closure makes your code easier to read, debug, and reuse. You don't need to have read the previous articles to follow this one. However, the .NET 5 Web API project is created from scratch in the first article, so reference that article if you want to learn to build a CRUD Web API using .NET 5.

Download Starting Projects

The best way to learn to use the technologies presented in this article is to follow along and type in the samples. I've created a download with two starting applications, a .NET 5 Web API project, and a Web Server project (either MVC or node). Download these projects at www.pdsa.com/downloads and click on the link entitled "CODE Magazine - How to Use the Fetch API (Correctly)". After downloading the ZIP file, unzip it into a folder where you'll then find three folders. The \Samples folder contains the finished samples for both MVC and node. The \Samples-WebAPI is the .NET 5 Web API project. The \Samples-Start folder contains an MVC and a node project, one of which you'll use to follow along with this article.

In addition to the source code, you also need the Microsoft AdventureWorksLT sample database. I've placed a version of it on my GitHub account that you can download at https://github.com/PaulDSheriff/AdventureWorksLT. Install this database into your SQL Server.

Navigate into the folder Samples-WebAPI and load that folder in Visual Studio Code or Visual Studio 2019. Open the appsettings.json file and modify the connection string to point to your SQL Server where you installed the AdventureWorksLT database. Run this project and when the browser appears, type in http://localhost:5000/api/product. If you have everything installed correctly, you should get an array of JSON product objects displayed in the browser. Leave the Web API project running as you make your way through this article.

If you're most familiar with MVC, navigate into the folder \Samples-Start\AjaxSample-MVC. Load this folder into another instance of Visual Studio Code or Visual Studio 2019. Click on the Run > Start Debugging menu item to ensure that your browser launches and displays a Product Information page.

If you're most familiar with node, navigate into the \Samples-Start\AjaxSample-Node. Load this folder into another instance of Visual Studio Code. Open a terminal window and type in npm install to load all dependencies on your computer. Next, type in npm run dev to start the lite-server and display a browser with a blank Product Information page. One thing to note when using the node version is that if you open the index.html page in VS Code, you see that it reports four errors. They're not really errors; it's just that VS Code doesn't understand the templating engine you're using. The templating engine is explained later in this article.

Application Architecture

As you read this article, you're going to learn how to put together a CRUD application using the Fetch API. I prefer to show you a more robust, real-world example rather than just a simple sample. To that end, I highly recommend that you create separate .js files as I'm doing in this article, so you have reusable code for any additional pages, and for future applications.

Figure 1 shows you the overall architecture for the application you're going to build. The site.js file is used on almost all pages in your site and contains a global appSettings object. The appSettings object contains properties to hold information that you're going to need for your entire application. I've already placed a few properties in here for you. The most important one is the apiUrl property that contains the URL for where your Web API server is located.

var appSettings = {  
    "apiUrl": "http://localhost:5000/api/",  
    "msgTimeout": 2000,  
    "networkErrorMsg": "A network error has occurred.      
                       Check the apiUrl property to         
                       ensure it is set correctly."}

The ajax-common.js file contains an Immediately Invoked Function Expression (IIFE) assigned to a global variable named ajaxCommon. You put methods into this closure to handle generic Ajax calls and error handling. This file is referenced on any page where you make Ajax calls. The product.js file is where you write methods to work specifically with the product page (in this case, the index page). The index page is going to display a table of products, and allow you to add, edit, and delete product information by calling the Web API. If you have more pages, like a customer page or an employee page, create customer.js and employee.js files, respectively, for the functionality of each of those pages.

Figure 1: A good application architecture separates functionality into different closures.
Figure 1: A good application architecture separates functionality into different closures.

Set Options in the productController

The appSettings object can be used everywhere in your application because it's declared outside of any closure. However, a better approach is to pass values from the appSettings object into each closure that needs the settings. This allows you to modify the settings for each page if needed. Open the product.js file, located in the \scripts folder in the node project and in the \wwwroot\js folder in the MVC project, and just below the comment that reads // Private Variables, add the following literal object.

let vm = {  
    "options": {    
        "apiUrl": "",
        "urlEndpoint": "product",
        "msgTimeout": 0  
    }
};

Instead of having multiple variables with a closure, I prefer to create a literal object called vm, which stands for “View Model”. Within the vm object is where you add as many properties as needed for your page. Throughout this article, you're going to add quite a few, but this first one is for the options you wish to pass in from outside of the closure. To set the values in the vm object, add a public function within the return literal object located at the end of the closure.

return {  
    "setOptions": function (options) {
        if (options) {      
            Object.assign(vm.options, options);    
        }  
    }
};

Adding the setOptions property in the return object defines the method setOptions() as a public method that can be called by referencing the productController variable. Don't type this in anywhere, but the code snippet below is an example of how you can call this setOptions() method and set one or more of the properties in the vm.options property.

productController.setOptions({   
    "apiUrl": "http://localhost:5000/api/",  
    "urlEndpoint": "product"
});

The apiUrl property is self-explanatory, as it represents the Web API server name where all your controllers are located. The urlEndpoint property is what's added on to the end of the apiUrl to provide the specific controller within your server to call for this page. If you put these two together, you end up with the URL http://localhost:5000/api/product. Within the productController, add one more method called get() that looks like the following code snippet.

function get() {
    let msg = vm.options.apiUrl +
              vm.options.urlEndpoint;  
        msg += " - ";  
        msg += JSON.stringify(vm.options);

        displayMessage(msg);}

The code above is going to allow you to display the data within the vm.options object so you can ensure that your setOptions() method is working as you expect it to. Add the get() method to the return object to expose this as a public method from the productController variable.

return {  
    "setOptions": function (options) {
        if (options) {
            Object.assign(vm.options, options);
        }  
    },
    "get": get
};

Display Messages

Near the top of the index page, you'll find two <div> elements defined with bootstrap row and column classes, as shown in the following code snippet. Within the <div> elements are two labels; one to display informational messages and one to display error messages. Both labels are styled using a style from the site.css file in the project. They're both also styled with “d-none”, which is a bootstrap class to make each of these labels invisible.

<div class="row">  
    <div class="col">    
        <label id="message"
               class="infoMessage d-none">
        </label>
        <label id="error"
               class="errorMessage d-none">    
        </label>  
    </div>
</div>

Create a new method named displayMessage() in the productController closure to write text into the message label when you want to display an informational message to the user. If a message is passed in, the message is set into the label's text area, and the label is made visible by removing the class “d-none”. If an empty message is passed in, the label is hidden by adding the “d-none” class to the label.

function displayMessage(msg) {  
    if (msg) {
        $("#message").text(msg);    
        $("#message").removeClass("d-none");  
    }  
    else { 
          $("#message").addClass("d-none");  
    }
}

Open the index.cshtml or the index.html file and at the bottom of the page, modify the window.onload function to look like the following code snippet. You're building a literal object with only that set of properties you want to update in the vm.options property within the productController closure.

window.onload = function () {  
    productController.setOptions({    
        "apiUrl": appSettings.apiUrl,    
        "msgTimeout": appSettings.msgTimeout  
    });  
    productController.get();
}

Try It Out

Save all your changes and run the project to display the index page. You should see the full URL to the product controller being displayed in the <label> on the index page. You also see the options property with its properties displayed. This proves that the setOptions() method did set the options property correctly when called from the index page.

http://localhost:5000/api/product -
{  
    "apiUrl":"http://localhost:5000/api/",  
    "urlEndpoint":"product",  
    "msgTimeout":2000
}

Getting Started with the Fetch API

The fetch API is similar to using the jQuery's $.ajax() method. You make a request to a Web API endpoint and a promise object is returned in either a fulfilled or a rejected state. In the get() method in your productController, modify the get() method to look like the following.

function get() {  
    fetch(vm.options.apiUrl +
          vm.options.urlEndpoint)    
        .then(response => response.json())    
        .then(data => displayMessage(JSON.stringify(data)))    
        .catch(error => {displayError("*** in the catch()
            method *** " + error);    
    });
}

This code uses the fetch() function to make a call to the Web API Product controller class. When the Ajax call is fulfilled, the response parameter is passed to the first .then() method. Extract the body of the response object using the .json() method. The result from calling the .json() method is an array of product objects retrieved from the Web API. This array is passed to the second .then() method as the data parameter. Within the second .then() method is where you do something with the data, such as display it in an HTML table, or fill in a drop-down list. For now, you're just putting that data into the informational message label.

The .catch() method from the fetch() function is called when a network error occurs while attempting to make the Web API call. In the .catch() method, call a method named displayError() that can display an error message in the error label. In the productController closure, type in the code shown below.

function displayError(msg) {
    if (msg) {    
        $("#error").text(msg);    
        $("#error").removeClass("d-none");  
    }  
    else {    
        $("#error").addClass("d-none");  
    }
}

As shown previously, there's a label with an ID of error. When you receive an error, display that error in this label, as it's styled with a red background and white lettering, so it stands out to the user. If an error message is passed in, the message is set into the label's text area, and the label is made visible by removing the class “d-none”. If an empty message is passed in, the label is hidden by adding the “d-none” class to the label.

Try It Out

Save the changes and run your project. You should see an array of product objects displayed in the message label.

The Fetch API Exception Handling is Erratic

The Promise object returned by fetch() doesn't reject an error when an HTTP error status is returned (400 or greater) like most normal APIs do. For example, 400 and 500 status codes don't cause a rejection, but a 404 may or may not cause a rejection of the promise. A network failure, a CORS error, and a few other types also cause a rejection. This section of the article illustrates each of these scenarios. To force the get() method to go into the .catch() method, remove one of the zeros (0) from the port number 5000 in the apiUrl property in the appSettings object located in the site.js file.

http://localhost:500/api/

Save your changes and run the project. Open your browser tools (F12) to get to the console window and you should see an error message that looks like the following, if you're using the Chrome browser.

Failed to load resource: net: :ERR_CONNECTION_REFUSED

In the error message label on the index page, you should see something that looks like the following message.

*** in the catch() method *** 
TypeError: Failed to fetch

Because the port number doesn't exist, a network error is detected by the Fetch API. Because the fetch() function is unable to reach the Web API server, the .catch() method is called.

Another way to cause a rejection of the promise is to get a 404 (not found) status code returned from the Web API server. Put the apiUrl property back to the normal port number of 5000. In the get() function, add on a “/9999” to the fetch() call.

fetch(vm.options.apiUrl + vm.options.urlEndpoint + "/9999")

Save your changes and run the project. Open your browser tools to get to the console window and you should see the following error message reported:

Failed to load resource: the server responded with a status of 404 (Not Found)

In the error message label on your page, you should see the following message:

*** in the catch() method ***
SyntaxError: Unexpected token C in JSON at position 0

The product ID of 9999 doesn't exist in the database, so the Web API server returns a 404 status code with the text “Can't find Product with ID=9999”. So, why did you end up in the .catch() block? After all, you did get to the Web API server, so it wasn't a network error. If you look at the error message returned by the Fetch API, it says there was a SyntaxError. The problem is that what's returned by the 404 isn't JSON data, but a simple text string. When you call the response.json() method on a text string, an exception is thrown because trying to perform a JSON.parse() on a text string causes control to go to the .catch() method. Don't worry, you're going to learn how to solve this problem in the next section of this article.

What happens in the case of a bad request (400) or an internal server error (500), or any of the many other HTTP error status codes? The answer is that you won't know until you try each one. I'm going to show you how to create a method to handle the most common errors and display what it's appropriate. Let's take a look at a 400 status code. Add a “/a” to the end of your URL in the get() method as shown below:

fetch(vm.options.apiUrl +
      vm.options.urlEndpoint + "/a")

Save your changes and run the project. Open your browser tools to get to the console window and you should see the 400 error message reported by your browser.

Failed to load resource: the server
responded with a status of 400 (Bad Request)

In the error message label on your page, you should see a JSON object that looks like the following.

{  
    "type":"https://tools...",  
    "title":"One or more validationerrors occurred.",  
    "status":400,  
    "traceId":"00-...",  
    "errors": {     
        "id": ["The value 'a' is not valid."]}
}

Notice that you did not go into the .catch() method, but instead ended up with the literal object reported by the second .then() method. As you can see, trying to handle errors using the Fetch API can be very confusing because it seems very random what type of error calls the catch, and which ones try to process the response object passed back.

Exploring the Response Object

To help with handling exceptions while using the Fetch API, you need to learn more about the response object that you see in the first .then() method. When you get the response object in the first .then() method, there are a few properties that are important to you; ok, status, and statusText. If the call is successful, the ok property is set to a true value, the status property is set to the HTTP status code, and the statusText property is set to the corresponding message of the status code. For example, if the status property is set to 200, the statusText property is set to “OK”.

If the ok property is set to false, this means the call failed for some reason other than a network error. The status and statusText properties are still set with the corresponding HTTP status code and message. Depending on the HTTP status code, you're going to use two different methods to retrieve the data associated with that status code. For example, if you receive a 404 status code, use the response.text() method to retrieve the actual text message sent back from your Web API controller. If you receive a 400 status code, use the response.json() method to retrieve a JSON object filled with additional properties about what went wrong. For a 500 status code, use the response.json(), however, you'll then find the actual message returned from the Web API method within the message property on the object returned.

So with this in mind, re-write your get() function (Listing 1) to return response.json() if the ok property is true, and return response.text() if the ok property is false. It's better to get the text version of the data when ok is false so you don't cause an error attempting to convert the response to JSON when it could be text. If you return text data to the second .then() method, you can always parse it to JSON depending on the number in the status property. Let's test getting each of the various successful and error codes you looked at previously.

Listing 1: Check the response.ok property to determine if the Ajax call was successful or not.

function get() {  
    fetch(vm.options.apiUrl +   
          vm.options.urlEndpoint)
     .then(response => {
         if (response.ok) {  
             return response.json();
         } 
         else {  
             return response.text();
        }
    })
    .then(data => displayMessage(JSON.stringify(data)))    
    .catch(error => {displayError("*** in the catch()  method *** " + error);    
    });
}

Get a 200 Status

Change the apiUrl property back to "http://localhost:5000/api/". Modify the get() function to look like the code shown in Listing 1 and save your changes. Run the project and you can see the array of product objects displayed in the message label.

Get a 404 Status from Your Web API Method

Change the fetch() call in the get() method to add to the URL a “/9999”. The value “9999” is an invalid product ID so the Web API server returns a 404 (Not Found) status code.

fetch(vm.options.apiUrl +
      vm.options.urlEndpoint + "/9999")

Save the change, run the project, and you should see the text “Can't find product with ID=9999.” displayed in the message label. This message is being returned from the Web API Get(int id) method.

Get a 404 Status from a Non-Existent API Endpoint

The other kind of 404 status code is when you call an API that doesn't exist. Change the fetch() call in the get() method to look like the following code snippet.

fetch(vm.options.apiUrl + "prod/9999")

Save the changes and run the project. Running this fetch() function produces an empty string in the message label. You're going to learn how to take care of this in the next section of this article.

Get a 400 Status

The next status code to test is a 400 (Bad Request). You can force this error to occur by passing in a letter to the Get(int id) method. Because the Web API method doesn't accept a letter, submitting this on your URL line causes the 400. Change the call to the fetch() function to look like the following.

fetch(vm.options.apiUrl +
      vm.options.urlEndpoint + "/a")

Save the changes, run the project, and you should see text that looks like the following in the message label. The return value is a JSON object, but it's being reported as text.

{  
    "type":"https://tools...",  
    "title":"One or more validationerrors occurred.",  
    "status":400,  
    "traceId":"00-...",  
    "errors": {     
        "id": ["The value 'a' is not valid."]}
}

From just the few status codes you tried here, you can see that the code in Listing 1 handles only the 200 and 404 calls correctly. Of course, these two status codes are about 95% of use cases for a typical business application. However, to be complete, you should also handle 404 for non-existent API endpoints, 400 for bad requests, and 500 for other exceptions that might be thrown by the Web API server.

Create Helper Functions

To handle the various status codes returned by the Fetch API, it's important to preserve a few properties from the response object so you can check them when you get into the second .then() method. To accomplish this, add a new literal object to the vm object in the productController. Create a property called lastStatus just below the options property you added earlier in this article.

let vm = {  
    "options": {    
        "apiUrl": "",    
        "urlEndpoint": "product" 
    },
    "lastStatus": {
        "ok": false,    
        "status": 0,    
        "statusText": "",    
        "response": null  
    }
};

The ok property is set to either a true or false value. The status property is set to the HTTP status code (200, 404, etc.) from the last request. The statusText property is set to the text that goes along with the HTTP status code such as “OK” or “Not Found”. The response property is populated in the second .then() method as you're going to see in the next code listing.

Add the processResponse() method shown below to the productController. In this method, copy the properties from the response object into the properties of the lastStatus object. Because the lastStatus object is created outside of any methods within productController, this object is available to all methods. Once you have the properties set, check the ok property to determine if you should return the results from response.json() or response.text() back to the first .then() method.

function processResponse(resp) {  
    // Copy response to lastStatus properties  
    vm.lastStatus.ok = resp.ok;  
    vm.lastStatus.status = resp.status;  
    vm.lastStatus.statusText = resp.statusText;  
    vm.lastStatus.url = resp.url;

    if (vm.lastStatus.ok) { 
        return resp.json(); 
    }
    else {
        return resp.text();  
    }
}

When working with the Fetch API response object, you need to be aware that once you've processed the body of the response object using the .json() or the .text() methods, you can't read the body again. This is a one-time operation. Modify the get() function to look like Listing 2. In the first .then() method, you pass the response object to the processResponse() method you just created. In the second .then() method, either the JSON or the text data is passed into the data parameter. The first thing you should do is to assign the data parameter into the response property of the lastStatus property. This preserves the original data in case it's needed.

Listing 2: Create separate functions to handle processing the response, and handling errors.

function get() {
    fetch(vm.options.apiUrl +   
          vm.options.urlEndpoint)    
        .then(response =>processResponse(response))    
        .then(data => {
            // Fill lastStatus.response
            // with the data returned 
            vm.lastStatus.response = data;

            // Check if response was successful      
            if (vm.lastStatus.ok) {     
                displayMessage(JSON.stringify(data)); 
            }
            else {
                displayError(ajaxCommon.handleError(vm.lastStatus));      
            }
            })
            .catch(error => displayError(ajaxCommon.handleAjaxError(error)));
}

If the lastStatus.ok property is true, you do something with the data returned from the Web API. For now, you're just going to display it into the message label. Later in this article, you're going display that product data in an HTML table. If the lastStatus.ok property is false, call an ajaxCommon.handleError() method passing in the lastStatus object. You're going to write the handleError() method shortly. If the .catch() method is called because of a network error, pass the error object to an ajaxCommon.handleAjaxError() method that you're going to write soon.

It's now time to add the two methods handleAjaxError() and handleError() into the ajaxCommon closure. Open the ajax-common.js file and just below the // Private Functions comment block in the ajaxCommon closure, create the handleAjaxError() method as shown below. Because the .catch() method is only called when something catastrophic happens, this code is going to assign the generic error message from the appSettings object to the variable msg. It then logs the error parameter and the msg variable to the console. The msg variable is returned from this method so you can display it in the error label if you wish.

function handleAjaxError(error) {  
    let msg = appSettings.networkErrorMsg;    

    console.error(error + " - " + msg);

    return msg;}

Add the handleError() method, as shown in Listing 3, just below the handleAjaxError() method you just created. This method checks the HTTP status code to determine how to handle the data returned from the Web API server. Remember, depending on the HTTP status code, you may retrieve just a simple piece of text, or a JSON object. Looking back at Listing 2, you can see in the second .then() method that the data passed into that .then() method is stored into the lastStatus.response property. It's this data that could either be text or a JSON object based on the status code. To expose the two methods publicly from the ajaxCommon closure, modify the return literal object to look like the following:

return {  
    "handleAjaxError": handleAjaxError,  
    "handleError": handleError
};

Listing 3: Based on the HTTP status code you need to handle the error response differently.

function handleError(lastStatus) {  
    let msg = "";
    switch (lastStatus.status) {    
        case 400:      
            msg = JSON.stringify(lastStatus.response);      
            break;    
        case 404:
            if (lastStatus.response) {
                msg = lastStatus.response;      
            }
            else {
            msg = `${lastStatus.statusText}
                 - ${lastStatus.url}`;
            }
            break;
        case 500:
            msg = JSON.parse(
                lastStatus.response).message;
                break;  
            default:
                msg = JSON.stringify(lastStatus);      
                break;  
    }
    if (msg) {   
        console.error(msg);
    }

    return msg;
}

Try It Out

Make sure you set the apiUrl property in the appSettings object back to the valid endpoint "http://localhost:5000/api/". Save your changes and run the project. If you typed everything in correctly, you should still see the array of product objects displayed in the message label. Now, try each of the error conditions outlined in the previous section of this article to ensure that you're getting the same errors reported as before.

Display All Products in an HTML Table

Instead of displaying all the products in the message label, let's put the array of product data into an HTML table, as shown in Figure 2. There are many different methods you can use to create an HTML table. I'm going to use a templating engine called mustache.js, which you can find at https://github.com/janl/mustache.js. A templating engine allows you to create some HTML in a <script id="dataTmpl" type="text/html"> tag, add some replaceable tokens in the format of {{property_name}}, then combine the HTML from this tag with data in an array of a literal object. The mustache templating engine iterates over the array of data and replaces the tokens with the data from each element of the array and places the resulting HTML into the DOM at the location you specify.

Figure 2: Create an HTML table using a templating engine such as mustache.js.
Figure 2: Create an HTML table using a templating engine such as mustache.js.

To make this work, add two new properties to the vm object literal, as shown in the following code snippet:

let vm = {  
    "list": [],  
    "mode": "list",  
    // REST OF THE PROPERTIES HERE
}

Change the get() function by adding code to set the vm.mode property to “list” immediately after the function declaration. Within the second .then() method, remove the line of code displayMessage(JSON.stringify(data)); that's located in the if (vm.lastStatus.ok) block. Replace the lines of code within the if statement, as shown in the following code snippet:

function get() {  
  vm.mode = "list";
  // REST OF THE CODE    
      if (vm.lastStatus.ok) {
          // Assign data to view model's 
          // list property
          vm.list = data;      
          // Use template to build HTML table      
          buildList(vm);    
      }  
      // REST OF THE CODE
}

Open the index page and locate the <table> shown in Listing 4. Notice that the <thead> element is filled in with the appropriate headers needed to describe the product data. However, the <tbody> element is blank. It's into the blank <tbody> element where you create the appropriate <tr> and <td> elements to match the <th> elements in the header with the individual property values from each row in the array of product data.

Listing 4: Create the HTML table, but leave the blank for the templating engine to fill in.

<table id="products"
       class="table table-bordered table-striped table-collapsed">
  <thead>    
      <tr>      
          <th>Action</th>
          <th>Product ID</th>
          <th>Product Name</th>
          <th>Product Number</th> 
          <th>Color</th>
          <th class="text-right">Cost</th> 
          <th class="text-right">Price</th>
      </tr>  
  </thead>  
  <tbody></tbody>
</table>

If you scroll down more in the index page, you'll find a <script> tag with a type of “text/html” (Listing 5). Inside this <script> tag, you can see a combination of HTML and replaceable tokens {{property_name}} that mustache uses to generate each row of the table. The token {{#list}} refers to the list property you just added to the vm literal object. The pound sign (#) informs mustache that this variable is the array to iterate over. Think of the two tokens {{#list}} and {{/list}} as the beginning and the ending of the loop respectively. As mustache loops through each item, it starts creating each row of the table. When it finds a {{property_name}} token, it looks into the current array item and extracts the property_name, such as productID or productNumber from the current product object and replaces the value of those properties into the location of the {{property_name}} token. Mustache continues building each row of HTML as it loops through each item in the product array. What's nice about placing the HTML into a <script> tag like this is that it's much more readable than if you used a normal JavaScript loop and had to build the HTML using normal strings.

Listing 5: Create a template to generate an HTML table within a <script> tag.

<script id="dataTmpl" type="text/html">  
    {{#list}}  
    <tr>    
        <td>      
            <button type="button"       
                    class="btn btn-primary" 
                    onclick="productController.getEntity({{productID}});">
                        Edit      
            </button> 
            &nbsp;
            <button type="button"       
                    class="btn btn-danger" 
                    onclick="productController.deleteEntity({{productID}});">
                        Delete 
            </button>
        </td>
        <td>{{productID}}</td>    
        <td>{{name}}</td>    
        <td>{{productNumber}}</td>    
        <td>{{color}}</td>    
        <td class="text-right">{{standardCost}}</td>
        <td class="text-right">{{listPrice}}</td>  
    </tr>
    {{/list}}
</script>

So how do you use the mustache templating engine to use the code in the <script> tag and combine that with the data you put into the vm.list property? In the code you just added to the get() function, you set the vm.list property with the array of products, and you then call a method named buildList(). Add this buildList() method in the productController using the code presented below.

function buildList(vm) {  
    // Get HTML template from <script> tag  
    let template = $("#dataTmpl").html();

    // Call Mustache passing in the template and  
    // the object with the collection of data  
    let html = Mustache.render(template, vm);

   // Insert the rendered HTML into the DOM  
   $("#products tbody").html(html);

    // Display HTML table and hide <form> area  
    displayList();
}

The buildList() function first reads the HTML from the script tag using the jQuery html() method and puts that HTML into the variable named template. Next, the Mustache.render() method is called passing in the template variable and the vm object that contains the list property. The render() method passes back the HTML it generated into a variable named html. Use the jQuery html() method to set the HTML generated by mustache into the <tbody> element in the products table.

In the index page, there's a <div id="list"> wrapped around the products table. The <form> tag on the page has an ID of detail. Both elements are hidden by default because they are set with the attribute of class="d-none". Either the <table> or the <form> is displayed at any one time, so you need a function named displayList() to remove the “d-none” class from the list and add the “d-none” class to the detail.

function displayList() {  
    $("#list").removeClass("d-none");  
    $("#detail").addClass("d-none");
}

Try It Out

Save all the changes you made and run the project. All the products should now appear in the HTML table in your browser, as shown in Figure 2.

Get a Single Product

The only difference in the Fetch API code between fetching all rows from the product table and a single row is to include a forward-slash and the product ID to retrieve on the URL, for example, http://localhost:5000/api/product/710. Open the product.js file and add a getEntity() function to look like Listing 6.

Listing 6: Retrieve a single product using Fetch.

function getEntity(id) {  
    vm.mode = "edit";
    
    // Retrieve a single entity  
    fetch(vm.options.apiUrl + 
          vm.options.urlEndpoint + "/" + id)    
      .then(response => processResponse(response))    
      .then(data => {
          if (vm.lastStatus.ok) {
              // Fill lastStatus.response
              // with the data returned
              vm.lastStatus.response = data;

              // Display entity
              setInput(data);

              // Unhide Save/Cancel buttons
              displayButtons();

              // Unhide detail area
              displayDetail();      
          }
          else {
              displayError(ajaxCommon.handleError(vm.lastStatus));      
          } 
      })    
      .catch(error => displayError( 
          ajaxCommon.handleAjaxError(error)));
}

There are a few more methods you need to add to the productController to support displaying the product detail. Add a setInput() method that takes a product object and places each property's value into the appropriate <input> tag within the <form> element.

function setInput(entity) {  
    $("#productID").val(entity.productID);  
    $("#name").val(entity.name);  
    $("#productNumber").val(entity.productNumber);  
    $("#color").val(entity.color);  
    $("#standardCost").val(entity.standardCost);  
    $("#listPrice").val(entity.listPrice);  
    $("#sellStartDate").val(entity.sellStartDate);
}

The Save and Cancel buttons aren't hidden currently, but you're going to be making them disappear later in this article. When you get a product and are displaying the detail area with the <form> element, call a displayButtons() method to ensure that those two buttons are visible using the following code.

function displayButtons() {  
    $("#saveButton").removeClass("d-none");  
    $("#cancelButton").removeClass("d-none");
}

As mentioned previously, only the table or the detail area can be displayed at a time. Add a method named displayDetail() to hide the HTML table and display the detail area within the <form> element.

function displayDetail() {  
    $("#list").addClass("d-none");  
    $("#detail").removeClass("d-none");
}

If the user clicks on the wrong Edit button next to a product, they may wish to go back to the HTML table. This functionality is what the Cancel button is for. Add a new method called cancel() into the productController. This method first hides the <form> detail area by adding the “d-none” class back to the form. It then clears any messages within the message label. Finally, it calls the get() method which refreshes the data from the Web API and displays the HTML table. You don't necessarily need to call the get() method if you don't want to, you could simply call the displayList() method and have it redisplay the table of product data.

function cancel() {  
    // Hide detail area  
    $("#detail").addClass("d-none");  
    // Clear any messages  
    displayMessage("");  
    // Display all data  
    get();
}

The last thing to do to display the single product in the form element is to expose two of these methods publicly from the productController closure by modifying the return object. Both of these functions are called from buttons on the index page and thus need to be exposed publicly.

return {  
    "setOptions": function (options) {
        if (options) {
            Object.assign(vm.options, options);    
        }  
    },
    "get": get,  
    "getEntity": getEntity,  
    "cancel": cancel
};

Try It Out

Save all your changes and run the project. Click on one of the Edit buttons next to a product to see the detail page appear with the product information filled into each input field. Click on the Cancel button to return to the HTML table of products.

Display a Blank Product for Adding

When you want the user to add a new product, you need to present a blank detail page to them. Use the same <form> and <input> elements you use for editing, just create an empty product object to load into those <input> elements. Create a new method named clearInput() in the productController as shown in the code below. Once the new product object is created with any default values, pass this object to the setInput() method.

function clearInput() {  
    let entity = {
        "productID": 0,    
        "name": "",    
        "productNumber": "",    
        "color": "",    
        "standardCost": 0,    
        "listPrice": 0,    
        "sellStartDate": new Date().toLocaleDateString()  
    };

setInput(entity);
}

On the index page, there's an Add button that, when clicked calls, a method named productController.add(). Create the add() method in the product controller, as shown in the code below. Notice that the mode property is set to “add” whereas in the getEntity() method you set the mode property to “edit”. This will be used in the save() method.

function add() {  
    vm.mode = "add";
    // Display empty entity  
    clearInput();

    // Display buttons  
    displayButtons();

    // Unhide detail area  
    displayDetail();
}

Because this method needs to be called from outside the productController, modify the return object to include this new add() method.

return {  
    // REST OF THE CODE HERE
    "getEntity": getEntity,  
    "cancel": cancel,  
    "add": add
};

Try It Out

Save all your changes and run the project. Click on the Add Product button just above the HTML table and you should see a blank set of input fields appear.

Create a Save Method

After the user adds or edits a product, they need to click on the Save button to send that information to the Web API for storage into the Product table. The Save button calls a method named productController.save(). Add this save() method to the productController as shown below. The code checks the vm.mode property to see if it's “add” or “edit”. If the mode is set to “add”, a method named insertEntity() is called. If the mode is set to “edit”, a method named updateEntity() is called.

function save() {  
    // Determine method to call   
    // based on the mode property  
    if (vm.mode === "add") {    
        insertEntity();  
    } else if (vm.mode === "edit") {    
        updateEntity();  
    }
}

You're not going to write the code to send the data to the Web API when they insert a new product yet. Instead, write some code to illustrate the sequence of events that are going to happen after the insert or update is successful. In the insertEntity() method shown below, you're going to hide the Save and Cancel buttons after successfully inserting a record. You then display a message to the user that the data was inserted. You want this message to be displayed for a couple of seconds, so you use the setTimeout() function to wait the amount of milliseconds you created in the appSettings object and passed into the productController using the setOptions() method. Once the message has been displayed for that amount of time, call get() to retrieve all of the data again and redisplay the HTML table of products. Finally, clear the message that was displayed.

function insertEntity() {  
    // Hide Save/Cancel buttons  
    hideButtons();

    displayMessage("Data Inserted.");

    setTimeout(() => {    
        // Redisplay all data    
        get();

        // Clear message    
        displayMessage("");  
        
    }, vm.options.msgTimeout);
}

The updateEntity() method follows the same design pattern as the insertEntity() method. If the update is successful, hide the Save and Cancel buttons and display a success message. This message is displayed for a couple of seconds and then the HTML table is redisplayed, and the message is cleared.

function insertEntity() {  
    // Hide Save/Cancel buttons  
    hideButtons();

    displayMessage("Data Inserted.");
    
    setTimeout(() => {    
        // Redisplay all data    
        get();

        // Clear message
        displayMessage("");  
    }, vm.options.msgTimeout);
}

Add a method named hideButtons() to make the Save and Cancel buttons invisible. Using jQuery, select each button and add the bootstrap class “d-none” to each button to make them invisible. The reason you're making these buttons invisible is that after you've successfully sent the product information to be inserted or updated, you want to display any data sent back by the Web API in the detail area for a couple of seconds. You don't want the user to be able to click on the buttons again, so by making them invisible, they're unable to click on them again.

function hideButtons() {  
    $("#saveButton").addClass("d-none");  
    $("#cancelButton").addClass("d-none");
}

The save() method is called from the Save button on in the index page, so you need modify the return literal object in the productController to expose this method.

return {  
// REST OF THE CODE HERE  
    "cancel": cancel,  
    "add": add,  
    "save": save
};

Try It Out

Save all your changes and run the project. Click on the Add Product button just above the HTML table and you should see a blank set of input fields appear. Click the Save button and you'll see a message appear above the input fields and the Save and Cancel buttons should disappear. After about two seconds, the message goes away, and the HTML table is redisplayed. Next, try clicking on an Edit button and click the Save button. Again, you should see a message appear and the Save and Cancel buttons disappear. After about two seconds, the message goes away and the HTML table is redisplayed.

Inserting a Product

All you've done so far is to pass a single parameter, the URL, to the fetch() function. There's a second parameter you can pass to the fetch() function, which is a literal JSON object called the options object. You're going to need to use this options object when inserting, updating, or deleting data. The options object has many properties and a few are illustrated in the following code snippet. For a complete list of the options object properties visit https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch.

fetch(URL, {  
    // *GET,POST,PUT,DELETE  
    method: 'GET',  
    // cors, no-cors, *cors, same-origin  
    mode: 'cors',  
    // *default, no-cache, reload,   
    // force-cache, only-if-cached  
    cache: 'no-cache',  
    // include, *same-origin, omit  
    headers: {    
        'Content-Type': 'application/json'  
    }
})

Modify the insertEntity() method you created in the last section of this article to look like Listing 7. This method gathers the product data from the user input fields on the index page using a method named getFromInput() and puts them into a variable named entity. An options object is created and sets the method, headers, and body properties with the appropriate data. The method property is set to POST to tell the Web API server which method to invoke. The Content-Type header is set to application/json to inform the server to expect JSON data. The body property is set to the stringified version of the entity literal object.

Listing 7: Insert a product object by setting the method property to ‘POST’.

function insertEntity() {  
    let entity = getFromInput();

    let options = {
        method: 'POST',    
        headers: {      
            'Content-Type': 'application/json'
        },
        body: JSON.stringify(entity)  
    };
    
    fetch(vm.options.apiUrl + 
          vm.options.urlEndpoint, options)    
      .then(response => processResponse(response))
      .then(data => {      
         if (vm.lastStatus.ok) {
             // Fill lastStatus.response with the data returned
             vm.lastStatus.response = data;

            // Hide buttons while 
            // 'success message' is displayed 
            hideButtons();

            // Display a success message        
            displayMessage("Product inserted successfully");
            
            // Redisplay entity returned
            setInput(data);

            setTimeout(() => {  
                // After a few seconds, redisplay all data
                get();

            // Clear message
            displayMessage("");        
            }, vm.options.msgTimeout);      
         }
         else {displayError(ajaxCommon.handleError(vm.lastStatus));}    
      })
      .catch(error => displayError(ajaxCommon.handleAjaxError(error)));
}

The fetch() function is invoked using the full URL and the options object. The processResponse() method converts the body property in the response object and passes it to the second .then() method. If the lastStatus.ok property is set to true, use the code you learned about in the last section to hide the Save and Cancel buttons, display a success method, and display the product data sent back from the server. The reason to redisplay the product data sent back from the server is that sometimes you might have a field that's generated by SQL Server and you might want to display that new value to the user. After a specified number of seconds, the HTML table is redisplayed, and the informational message is cleared.

In order to submit the product data, the user fills in to the input fields, so you need a method named getInput(), as shown in the code below. This method uses jQuery to gather the values from each input field and build a literal product object.

function getFromInput() {  
    return {    
        "productID": $("#productID").val(),    
        "name": $("#name").val(),    
        "productNumber": $("#productNumber").val(),    
        "color": $("#color").val(),    
        "standardCost": $("#standardCost").val(),    
        "listPrice": $("#listPrice").val(),    
        "sellStartDate": new Date($("#sellStartDate").val())  
    };
}

Try It Out

Save all your changes and run the project. Click on the Add Product button and enter the data shown in Table 1 into the input fields.

Click on the Save button and, if you've done everything correctly, you should see the message Product inserted successfully appear in the message label. After a couple of seconds, the HTML table will reappear, and you should see your new product appear as the first row in the table.

Updating a Product

When you click on the Edit button on one of the rows in the table, the product data is displayed in the detail area. You then modify any of the fields and click the Save button to update the data into the Product table. Modify the updateEntity() method you wrote earlier to make the Web API to accomplish this. Locate the updateEntity() method in the productController and change it to look like the code shown in Listing 8.

Listing 8: Add an updateProduct() function to be able to modify a product using the Fetch API.

function updateEntity() {  
    let entity = getFromInput();

    let options = {    
        method: 'PUT',   
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(entity)  
    };

    fetch(vm.options.apiUrl + 
          vm.options.urlEndpoint + "/" +
          entity.productID, options)
      .then(response => processResponse(response))
      .then(data => {   
          if (vm.lastStatus.ok) {   
              // Fill lastStatus.response with the data returned
              vm.lastStatus.response = data;

             // Hide buttons while 'success message' is displayed 
             hideButtons();

            // Display a success message        
            displayMessage("Product updated successfully");
            
            // Redisplay entity returned        
            setInput(data);

            setTimeout(() => {
                // After a few seconds, redisplay all data
                get();

                // Clear message    
                displayMessage("");
            }, vm.options.msgTimeout);      
          }
          else {
              displayError(ajaxCommon.handleError(vm.lastStatus)); 
          }    
      })
      .catch(error => displayError(ajaxCommon.handleAjaxError(error)));
}

In the updateEntity() method, you retrieve the product data input by calling the getFromInput() method. Create an options variable and set the method property to PUT to inform the Web API to call the method to update the data. The fetch() function is called using the URL endpoint and passing the product ID to update on the URL. In addition, the options object is passed as the second parameter to the fetch() function. The rest of the code is similar to what you just wrote for the insertEntity(). If the lastStatus.ok property is set to a true value, hide the Save and Cancel buttons and display a message to inform the user that the data was successfully updated. Display the product data sent back from the server in the input fields just in case any of the values have been updated by the server during the update process. Finally, after a couple of seconds, the HTML table is redisplayed, and the informational message is cleared.

Try It Out

Save all the changes you made and run the project. Click on one of the products and modify a couple of fields like the Cost and Price. Click the Save button and ensure that everything works correctly.

Deleting a Product

The final functionality to add to the index page is the ability for the user to delete a product. If the user clicks on the Delete button in one of the rows in the product table, you should prompt the user whether they really wish to delete that product. If they answer that they wish to perform the delete, call a deleteEntity() method in the productController. Add the code for the deleteEntity() method as shown in Listing 9 to the productController closure.

Listing 9: Add a deleteProduct() function to be able to delete a product using the Fetch API.

function deleteEntity(id) {  
    if (confirm(`Delete Product ${id}?`)) {
        let options = {
            method: 'DELETE'
        };
        
        fetch(vm.options.apiUrl +   
              vm.options.urlEndpoint + 
              "/" + id, options)    
            .then(response => processResponse(response))    
            .then(data => {     
                if (vm.lastStatus.ok) {
                    // Fill lastStatus.response with the data returned
                    vm.lastStatus.response = data;

                    // Display success message          
                    displayMessage("Product deleted successfully");
                    
                    // Redisplay all data
                    get();

                    setTimeout(() => {
                        // Clear message    
                        displayMessage("");
                    }, vm.options.msgTimeout); 
                }
                else {
                    displayError(ajaxCommon.handleError(vm.lastStatus));
                }   
            })
            .catch(error => displayError(ajaxCommon.handleAjaxError(error)));
    }
}

The first line of code in the deleteEntity() method calls the confirm() function to display a confirmation dialog to the user to which they must respond with either OK or Cancel. If they answer OK, the code within the if() block is executed. Create an options object with the method property set to DELETE. Pass the product ID passed into this method on the URL line as the first parameter to the fetch() function and the options object as the second parameter. In the second .then() method, if the vm.lastStatus.ok is set to true, a success message is displayed in the message label. The get() method is called to reload the HTML table. After a couple of seconds, the message in the label is cleared. Because the deleteEntity() method needs to be accessed from the index page, you need to modify the return literal object and add this method to the list of methods to be made public, as shown in the code below:

return {  
    // REST OF THE CODE HERE  
        "add": add,  
        "save": save,  
        "deleteEntity": deleteEntity
};

Try It Out

Save all the changes you made and run the project. Not all of the products in the Product table can be deleted because of relationships set up in the database. You should add a new product, then delete that new product to test the delete functionality.

Summary

The Fetch API is different from the XMLHttpRequest object and the jQuery $.ajax() method call. The fetch() function is a straight-forward API to use, but as you learned, the exception handling can be a little challenging. Hopefully, you now have a good design pattern to use if you wish to use this API. If you're already using the jQuery $.ajax() method, I recommend that you keep using it and not switch to the Fetch API. You definitely have more options using the $.ajax() method. For more information on a comparison between XMLHttpRequest and the Fetch API, check out the post at https://www.sitepoint.com/xmlhttprequest-vs-the-fetch-api-whats-best-for-ajax-in-2019/.

Table 1: Enter some valid values to insert into the Product table

FieldValue
Product ID0
Product NameA New Product
Product NumberNEW-000
ColorRed
Cost20
Price40
Sell Start Date< Today's Date >