Skip to main content

CSE 331: The data behind the page

lecture

Course motto: Your job is to deliver code that you know works.

A feature

Today's program is the guestbook with a program in the page, with one feature added: a box that shows only the messages that start with what you type in it. Type ada in the box, and the list shows ada's messages. Here is the page with the box, and line 8 is the only new line:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html>
<head>
  <title>Guestbook</title>
</head>
<body>
  <h1>Guestbook</h1>
  Show only: <input id="only">
  <ul id="messages">
  </ul>
  Name: <input id="name">
  Message: <input id="message">
  <button id="post">Post</button>
  <script src="app.js"></script>
</body>
</html>

Here is what the feature should do, with two messages posted, before and after you type ada in the box:

Two browser windows side by side, before and after typing ada
in the Show only box. Before, the box is empty and the list holds
ada: hello and bob: hi. After, the box holds ada and the list
holds ada: hello
alone.

The browser already has every message, since app.js asked the server for them when the page opened. So the filter is app.js's job, with no new request to the server.

Before you write any code, answer this: where, in the browser, are the messages? Here is app.js as you last saw it, its three functions:

 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Puts the messages on the page, one list item each, in place of
// whatever the list held before.
function show(messages) {
  const list = document.getElementById("messages");
  list.innerHTML = "";
  for (const message of messages) {
    const item = document.createElement("li");
    item.textContent = message;
    list.appendChild(item);
  }
}

// Asks the server for the messages, and shows them.
async function load() {
  const response = await fetch("/api/messages");
  const messages = await response.json();
  show(messages);
}

// Sends the name and message typed in, and shows what comes back.
async function post() {
  const fields = new URLSearchParams();
  fields.append("name", document.getElementById("name").value);
  fields.append("message", document.getElementById("message").value);
  const response = await fetch("/api/messages", {
    method: "POST",
    body: fields,
  });
  const messages = await response.json();
  show(messages);
  document.getElementById("message").value = "";
}

load asks the server for the messages and hands them to show. show puts one <li> in the page's tree for each and returns. post hands what the server sends back to show. Nothing keeps the messages show was handed. After show returns, the messages are in exactly one place in the browser: the page.

A filter

So the filter has to work on the page. Here is a filter function, added to app.js after post, with the two lines that wire it to the page:

41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// Removes from the page every message that does not start with the
// text in the filter box. An empty box removes nothing.
function filter() {
  const only = document.getElementById("only").value;
  const list = document.getElementById("messages");
  for (const item of list.querySelectorAll("li")) {
    if (!item.textContent.startsWith(only)) {
      list.removeChild(item);
    }
  }
}

document.getElementById("post").onclick = post;
document.getElementById("only").oninput = filter;
load();

Line 54 runs filter on every keystroke in the box, the way line 53 runs post on a click. filter reads the box, finds the list, and walks the list's items. Each item's text is a message as the server wrote it, with the name first: ada: hello. So an item whose text does not start with what is typed in the box is somebody else's, and line 48 removes it. An empty box removes nothing, since every message starts with the empty text. (Line 46 asks for the items with querySelectorAll, which hands back a list that stays the same while the loop removes items from the page. The list node's own children would shrink under the loop, and the loop would skip the item after each one it removed.)

Try it, with two messages posted: ada: hello and bob: hi. Type ada in the box. Bob's message goes, and the list shows ada: hello alone. The feature works.

Now clear the box. The list still shows ada: hello alone. Type bob. The list is empty:

Two browser windows side by side. After clearing the Show only
box, the box is empty and the list still holds ada: hello alone.
After typing bob, the box holds bob and the list is
empty.

The messages are not filtered. They are gone, and nothing you type brings them back. Press the browser's reload button, and both are back, because the server sends them again and load draws them again.

Where did bob's message go? Line 48 removed its <li> from the tree, and that <li> was the only copy of the message in the browser. show had put it there and kept nothing. Removing an item from the page removed the data.

The page is a drawing of the data

The browser draws the page from the tree. The program drew the tree from the messages. So the page is a drawing of the data, two steps removed from it, and a program that keeps its data only in the drawing loses whatever it removes from the drawing. Filtering removes some of it. So does showing the newest ten, or any other view that leaves something out.

The server still had every message, so a reload got them back. That is what the first guestbook did for every change: ask the server for the whole page again. It costs a request over the network for every question about data the browser already had. The point of a program in the page was to answer such questions without the trip.

What the program needs is a copy of the data, kept as data: a variable that holds the messages, with the page drawn from the variable and redrawn from it.

The data in a variable

Here is app.js written that way, whole:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// The page's program, with a filter box: typing in the box shows
// only the messages that start with what is typed. The browser runs
// it once the page has loaded. It asks the server for the messages,
// keeps them in a variable, and draws the page from the variable;
// when the Post button is clicked, it sends the new message to the
// server and keeps the messages the server sends back.

// The messages the server last sent, oldest first. The list on the
// page shows exactly the messages that start with the text in the
// filter box, which is all of them when the box is empty. Only
// update assigns this variable, and update redraws the page, so the
// claim is true between one event and the next.
let messages = [];

// Draws the page from messages: one list item for each message
// that starts with the text in the filter box, in place of whatever
// the list held before.
function show() {
  const only = document.getElementById("only").value;
  const list = document.getElementById("messages");
  list.innerHTML = "";
  for (const message of messages) {
    if (message.startsWith(only)) {
      const item = document.createElement("li");
      item.textContent = message;
      list.appendChild(item);
    }
  }
}

// Replaces the messages with the list the server sent, and redraws
// the page from it.
function update(sent) {
  messages = sent;
  show();
}

// Asks the server for the messages, and keeps them.
async function load() {
  const response = await fetch("/api/messages");
  update(await response.json());
}

// Sends the name and message typed in, and keeps what comes back.
async function post() {
  const fields = new URLSearchParams();
  fields.append("name", document.getElementById("name").value);
  fields.append("message", document.getElementById("message").value);
  const response = await fetch("/api/messages", {
    method: "POST",
    body: fields,
  });
  update(await response.json());
  document.getElementById("message").value = "";
}

document.getElementById("post").onclick = post;
document.getElementById("only").oninput = show;
load();

Line 13 is the variable. let declares a variable that will be assigned again, where const would not allow it. show takes no argument now. It reads the box on line 19 and draws the page from messages on lines 21 to 28, one item for each message that starts with the text in the box. load and post no longer call show. They hand the server's answer to update, on lines 41 and 53, and update assigns the variable and redraws. The box's oninput, on line 58, is show itself: a keystroke redraws the page from the variable through the new text.

Run the same experiment. Type ada, and the list shows ada: hello. Clear the box, and both messages are back. Type bob, and the list shows bob: hi. Filtering now changes what is drawn, and never what is kept.

One function changes the data

The variable and the page now say the same thing. What keeps them that way? Try the console, on the page with both messages posted. (Open the developer tools as you did before, on the Console tab.) Type a line that adds a message to the variable behind the program's back, and look at the page:

> messages.push("eve: yo")
3

> show()
undefined

After the first line, messages holds three messages, and the console prints the new length. The page still shows two. Nothing in the language connects the variable to the page. The page changes when something redraws it, and nothing did. The second line calls show, and the page shows three. (show returns nothing, which the console prints as undefined.)

So the program makes a rule for itself, and writes it down. Read lines 8 to 12 of app.js again. The comment above the variable is a claim about the variable and the page together:

The list on the page shows exactly the messages that start with the text in the filter box, which is all of them when the box is empty.

The lines after the claim say how the program keeps it true: only update assigns messages, and update redraws. Every other function that changes the data goes through update, and no function touches the list on its own. The claim is true between one event and the next, and not at every instant. A keystroke changes the box before the browser runs the box's callback, and in the middle of show the list is half drawn. But the browser's loop runs one callback at a time, and each callback leaves the claim true before it returns, so no callback ever finds it false.

What would break the claim? A function that assigned messages and forgot to call show, as the console just did by hand. Or a function that set the list's innerHTML on its own, so that the page said something the variable did not. Each is a bug that the page shows late, at the next redraw, far from the line that caused it. With the rule, a wrong page points at one function to read: update.

The same invariant, named

You have written claims of this shape before. A representation invariant is a claim about a class's fields that is true of every object of the class whenever no method of the class is running. The constructor makes it true. Every method may assume it when called and must make it true again before it returns.

The claim above messages is a representation invariant. Its fields are the variable and the page's tree. Its methods are the callbacks: load, post, and show, run by the browser's loop one at a time. load's first run does the constructor's job, and every callback leaves the claim true before it returns. The one difference is that nothing in the language marks these two fields as belonging together, the way a class marks its fields. The program marks them itself, with the comment and the rule.

When an await cut a function in two, the advice was to make the invariant true before it and assume only the invariant after it. Now the invariant has words, and a rule that keeps it. Any program with a screen has this field, in a browser or anywhere else, and some one piece of the program has to own it.

A framework keeps it for you

Many programs in the page are not written the way app.js is. They are written with a framework, code that calls your code, as the server's HttpServer calls handle. One of the most used is React, and its job is to keep the invariant you just wrote by hand. In React you write a component: a function from the program's state to the page it should show. You say once how the state is drawn. When the state is assigned, the framework redraws the page from it. Nobody calls show, so nobody can forget to.

Here is the guestbook's list as a component. Read it beside app.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// The guestbook's list as a React component, for reading beside
// app.js. React is not in the course's setup: running this takes a
// build step and the library's script, so no check runs it, and the
// notes show it as text. The names and calls are React's documented
// ones (react.dev, Describing the UI and Adding Interactivity).
// Posting is left out, to keep the listing to one idea.
import { useEffect, useState } from "react";

function Guestbook() {
  const [messages, setMessages] = useState([]);
  const [only, setOnly] = useState("");

  async function load() {
    const response = await fetch("/api/messages");
    setMessages(await response.json());
  }
  useEffect(() => { load(); }, []);

  const shown = messages.filter(
    (message) => message.startsWith(only));
  return (
    <div>
      Show only: <input value={only}
        onChange={(event) => setOnly(event.target.value)} />
      <ul>
        {shown.map((message, i) => <li key={i}>{message}</li>)}
      </ul>
    </div>
  );
}

Line 10 declares the messages as state, and gets back the value and the one function allowed to assign it, setMessages. That is update with the redraw built in: load on line 15 assigns the state, and React redraws. Lines 19 to 29 are show, written as the page the state should produce, with the HTML inside the JavaScript. A browser cannot read the file as it is. A build step turns it into JavaScript before it is served, so the lecture reads it rather than runs it.

Some of the syntax is new. The square brackets on line 10 take apart the pair that useState returns, the value and its setter. => writes a function without a name: on line 24, (event) => setOnly(event.target.value) takes the keystroke's event and sets only to the box's text. Line 17 asks React to run load once, after it first draws the page. On line 19, messages.filter is JavaScript's own, not the filter above: it returns a new list of the messages for which the function on line 20 returns true, and leaves messages as it was. Inside the HTML, braces hold JavaScript, such as {only} on line 23, and line 26 makes one <li> for each message shown, as show's loop did.

The framework asks something in return. Look at line 11 and lines 23 and 24. The filter box's text is state too, with its own setter, and the box is wired to it in both directions: the box shows the state, and every keystroke assigns it. In app.js the browser owned the box, and show read it when it needed to. React takes the box into the state, so that the page is a function of the state and nothing else. Forget the wiring, and the box stops taking keystrokes, since the page is redrawn from a state that never changed. The invariant is the same one you wrote by hand. Who maintains it has moved, from your update to the framework, and the price is that everything the page shows has to be state the framework knows about.

Exercises

Each answer is folded under its question. Write yours first.

1. Add a check to post so that a message with an empty name is not sent, and the box is left for the visitor to fill in. Why does the server still have to check for an empty name too?

Answer
  • The check. Before the fetch in post, read the name box's .value, and if it is the empty string, return without sending. The visitor sees nothing happen, so a message beside the box saying why is kinder.

  • Why the server checks too. The server's user is the browser, and the server can assume nothing about it. A visitor can open the console and call fetch with any fields they like, or replace app.js with their own program. The check in the page spares a visitor a round trip. Only the server's check decides what gets into the book.

2. Suppose post assigned messages itself and forgot to redraw, with every other line of app.js as it is:

44
45
46
47
48
49
50
51
52
53
54
55
// Sends the name and message typed in, and keeps what comes back.
async function post() {
  const fields = new URLSearchParams();
  fields.append("name", document.getElementById("name").value);
  fields.append("message", document.getElementById("message").value);
  const response = await fetch("/api/messages", {
    method: "POST",
    body: fields,
  });
  messages = await response.json();
  document.getElementById("message").value = "";
}

Post ada: hello on a fresh page. What does the list show? When does the message appear?

Answer
  • What the list shows. Nothing. The server added the message and sent it back, line 53 kept it in messages, and no line redrew the page. Post bob: hi too, and the list is still empty, with two messages in the variable.

  • When the message appears. At the next redraw, whatever causes it: a keystroke in the filter box, since the box's oninput is show, or a reload, which runs load. Type a in the box and ada: hello appears. Clear the box and both messages appear.

  • Why the rule. The claim above messages was false from the moment line 53 ran until the next redraw, and the page showed the bug somewhere else and later. The one function allowed to assign messages is update, and line 53 broke the rule by assigning it directly.

3. The filter could run on the server instead: the page sends the box's text, as part of the request, and the server answers with only the messages that start with it. Sketch how load and the manual would change. What does the page give up, and what does it get?

Answer
  • The change. load sends the box's text with its request, for instance as GET /api/messages?only=ada, and the manual promises the messages that start with that text. The server reads the text from the part of the address after the ? and filters the book before writing the JSON. The page draws what comes back.

  • What it gives up. A request over the network for every keystroke, and a wait before the list changes, where app.js answered from the variable at once.

  • What it gets. No copy to keep. The page holds nothing the server does not, so there is no claim about a variable and the page to keep true, and no way for the two to disagree. Which is better depends on how much data there is and how often the visitor asks.

Further reading