Skip to main content

CSE 331: Vanilla-JS guestbook

In this guide we will build the same guestbook as the companion no-JS guide, but using JavaScript in the browser. The server no longer renders HTML, but instead exposes a small JSON API. The browser loads a nearly-empty HTML page and runs JavaScript to fetch the data and build the visible page itself.

Get the final code here:

The server exposes two endpoints under /api/messages: GET returns the list of messages as JSON, POST accepts a new message as JSON and returns the updated list. The HTML page the browser loads is essentially empty. JavaScript in the browser calls the API, receives data, and builds the visible page by manipulating the DOM directly.

A JSON API

The server serves static files out of static/ and exposes two endpoints under /api/messages:

public class Server {

    private final List<Message> messages = new ArrayList<>();

    public static void main(String[] args) {
        Server s = new Server();
        Javalin.create(config -> {
            config.staticFiles.add("/static");
            config.routes.get("/api/messages", s::handleGetMessages);
            config.routes.post("/api/messages", s::handlePostMessage);
        }).start(8331);
    }

    private void handleGetMessages(Context ctx) {
        ctx.json(messages);
    }

    private void handlePostMessage(Context ctx) {
        Message m = ctx.bodyAsClass(Message.class);
        if (m.name() != null && !m.name().isBlank()
                && m.message() != null && !m.message().isBlank()) {
            messages.add(m);
        }
        ctx.json(messages);
    }
}

ctx.json(...) serializes its argument and sends it as the JSON response body. ctx.bodyAsClass(Message.class) reads the request body and deserializes it into a Message. The record's fields line up directly with the JSON object keys:

public record Message(String name, String message) {}

The POST handler returns the updated list so the client can re-render in one round trip. Jackson does the JSON serialization in both directions automatically.

The vanilla-JS guestbook on first load. The server sent a near-empty HTML page; the JavaScript fetched the (empty) message list and rendered the placeholder line.

Promises and async/await

When JavaScript code calls fetch(url) to send an HTTP request, fetch does not wait for the network. It returns immediately, with a value that represents "the response, eventually." That value is called a promise.

To do something when the response arrives, attach a callback with .then:

fetch("/api/messages").then(response => {
    console.log("got it", response);
});
console.log("kicked it off");

This logs "kicked it off" first, then "got it" some milliseconds later.

Parsing the body as JSON is also asynchronous, so response.json() returns another promise. With .then, the code nests:

function loadMessages() {
    fetch("/api/messages").then(response => {
        response.json().then(messages => {
            console.log(messages);
        });
    });
}

These nested then handlers get tedious. Javascript has a shorthand for this kind of code via async/await. Inside an async function, await waits for a promise and gives back its resolved value:

async function loadMessages() {
    const response = await fetch("/api/messages");
    const messages = await response.json();
    console.log(messages);
}

The two versions are equivalent. async/await is an abbreviation for .then, but the code reads top to bottom in the order it runs. One gotcha: await is a syntax error outside of an async function.

Calling the API with fetch

To POST a JSON body, pass fetch an options object:

const response = await fetch("/api/messages", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ name, message }),
});
const messages = await response.json();

The body is a string built with JSON.stringify, which serializes a JavaScript object into JSON syntax. The Content-Type header tells the server how to interpret the body. The server replies with the updated list, which response.json() parses back into a JavaScript array.

The vanilla-JS form with values typed in. Clicking Post will call fetch with a JSON body rather than submitting a form.

Checking response.ok

One surprise about fetch: a 4xx or 5xx response from the server is not treated as an error. The returned promise resolves successfully, with a response whose ok property is false (and whose status is the actual HTTP status). Code that only catches errors will silently ignore server failures.

Check response.ok (or response.status) explicitly:

try {
    const response = await fetch("/api/messages", { method: "POST", ... });
    if (!response.ok) {
        console.log("server error", response.status);
        return;
    }
    const messages = await response.json();
    // ... use messages
} catch (err) {
    // The network itself failed: server down, etc.
    console.log("network error", err);
}

The catch block only fires when the network itself fails. A non-2xx HTTP status means the server replied, but with an error.

On the server side, set an explicit status before sending the response so the client can branch on it cleanly:

if (m.name() == null || m.name().isBlank()) {
    ctx.status(400).json(Map.of("message", "Name is required."));
    return;
}

Status codes worth recognizing:

  • 200 OK: the normal success.
  • 400 Bad Request: the client sent something the server can't make sense of.
  • 404 Not Found: no handler matches the URL.
  • 500 Internal Server Error: the server crashed while handling the request.

Building the DOM

The HTML page the server sends is nearly empty:

<!DOCTYPE html>
<html>
<body>
  <h1>Guestbook</h1>
  <ul id="messages">
    <li><em>Loading...</em></li>
  </ul>
  <label>Name: <input type="text" id="name"></label><br>
  <label>Message: <input type="text" id="message"></label><br>
  <button onclick="doPost()">Post</button>
  <script src="app.js"></script>
</body>
</html>

The <ul> has no real messages in it. The <script> tag at the bottom loads app.js, which fills the page in.

JavaScript reaches into the page through the DOM (Document Object Model). Every HTML element is also a JavaScript object; modifying the object modifies the page. The most important entry point is document.getElementById:

const ul = document.getElementById("messages");

Given an element, the code can:

  • Read or set its text with element.textContent.
  • Read or set its HTML with element.innerHTML.
  • Read or set form input values with inputElement.value.
  • Build a new element with document.createElement("li") and attach it with parent.appendChild(newElement).

Putting these together, here is how app.js renders the list:

function render(messages) {
    const ul = document.getElementById("messages");
    ul.innerHTML = "";
    for (const m of messages) {
        const li = document.createElement("li");
        const strong = document.createElement("strong");
        strong.textContent = m.name;
        li.appendChild(strong);
        li.appendChild(document.createTextNode(": " + m.message));
        ul.appendChild(li);
    }
}

Buttons

The button has an onclick attribute naming a global function:

<button onclick="doPost()">Post</button>

When the user clicks, the browser calls doPost. That function (in app.js) reads the input values, sends a POST, and re-renders:

async function doPost() {
    const name = document.getElementById("name").value;
    const message = document.getElementById("message").value;
    const response = await fetch("/api/messages", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ name, message }),
    });
    const messages = await response.json();
    render(messages);
}

For the initial page load, app.js calls loadMessages() at the bottom of the file. That issues a GET, awaits the response, and renders the existing list.

After a click of Post, the JavaScript wiped the <ul> and rebuilt it from the server's JSON response.
The same end state as the no-JS version, reached by a very different path: the JavaScript built every <li> from JSON.