Skip to content
academia.sh

Lesson 09 / 20

Networking and Data

The networking layer carries a request to the screen through loading, error, and data states; an offline cache changes the first state. The lesson measures that when data arrives, the loading indicator is unmounted and the list is mounted.

Published Updated

In this lesson

  • Explains the request layer and the loading, error, and data states of a request
  • Measures that when data arrives, the loading indicator comes out unmounted and the list comes out mounted
  • Compares how an offline cache changes the command count at launch and the need for an indicator
  • Shows that a stale value can sit in a kept node
Contents

The previous lesson measured how state is held inside the app. The app’s most important data source, however, is outside: a server. Data from the server does not reach the screen at once. The request goes out, the response comes back, and in between the screen has to show the user something. This lesson covers that gap and the cache that closes it.

The question of the course does not change: what does this update do to the node in the view tree? The fate is kept, updated, mounted, or unmounted; the total of the updated, mounted, and unmounted nodes is the commands. We do not assign a fate by hand; the rule gives it:

// fate.mjs — classifies what an update does to each node of the view tree
export const FATES = ["kept", "updated", "mounted", "unmounted"];

export function el(type, props = {}, children = [], key = null) {
  return { type, props, children, key };
}

function segment(node) {
  return node.key === null ? node.type : `${node.type}[${node.key}]`;
}

function identity(node, index) {
  return node.key === null ? `#${index}` : `key:${node.key}`;
}

function sameProps(a, b) {
  const names = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
  return names.every((n) => JSON.stringify(a[n]) === JSON.stringify(b[n]));
}

function whole(node, path, fate, ops) {
  const here = `${path}/${segment(node)}`;
  ops.push({ fate, path: here });
  node.children.forEach((child) => whole(child, here, fate, ops));
}

export function reconcile(prev, next, path = "", ops = []) {
  if (prev === null) {
    whole(next, path, "mounted", ops);
    return ops;
  }
  if (next === null) {
    whole(prev, path, "unmounted", ops);
    return ops;
  }
  if (prev.type !== next.type || prev.key !== next.key) {
    whole(prev, path, "unmounted", ops);
    whole(next, path, "mounted", ops);
    return ops;
  }
  const here = `${path}/${segment(next)}`;
  ops.push({ fate: sameProps(prev.props, next.props) ? "kept" : "updated", path: here });
  const before = new Map(prev.children.map((child, i) => [identity(child, i), child]));
  const matched = new Set();
  next.children.forEach((child, i) => {
    const id = identity(child, i);
    matched.add(id);
    reconcile(before.has(id) ? before.get(id) : null, child, here, ops);
  });
  prev.children.forEach((child, i) => {
    if (!matched.has(identity(child, i))) reconcile(child, null, here, ops);
  });
  return ops;
}

export function printOps(ops) {
  ops.forEach((op) => console.log(`${op.fate}: ${op.path}`));
}

export function countFates(ops) {
  for (const name of FATES) {
    console.log(`${name}: ${ops.filter((op) => op.fate === name).length}`);
  }
  console.log(`commands: ${ops.filter((op) => op.fate !== "kept").length}`);
}

The model builds this fate’s rule; the model is not the framework itself. The rule compares trees; it does not see what the request does on the network.

The Request Layer and Three States

An app does not send network requests from scattered places. The request layer collects the address, the headers, and the error handling in one place; screens only say “give me the data at this address.” A request is a job spread over time, and its result returns as a promise (Promise): it either resolves with data or is rejected with an error. While the request is in progress, the screen is said to be in one of three states. In the loading state the response has not arrived yet. In the data state the response has arrived and the list is shown. In the error state the request has been rejected and the screen tells the user so.

These three states are three different nodes on the screen: a loading indicator, a list, and an error message. In the framework a request is usually started in a side effect hook: when the screen appears for the first time, the hook runs, the request goes out, the state changes when the response arrives, and the screen is rendered again.

// Platform code: OrderFeed
function OrderFeed() {
  const [status, setStatus] = useState({ phase: "loading", rows: [] });
  useEffect(() => {
    loadOrders().then((rows) => setStatus({ phase: "data", rows }));
  }, []);
  return <Feed status={status} />;
}

This fragment is not measured; the model does not run the framework or the network. In the model a request is a Promise chain in its own file, and the server is a fixed table held in memory.

AL1. The model replaces the real network with a table in memory: a known address resolves with data, an unknown address is rejected. Latency and disconnection do not enter the model; the model measures only which state the response arrives with.

// request.mjs — the request layer: sends a request to the in-memory fake server with a Promise
const SERVER = {
  "/orders": [
    { id: "a1", text: "Pen" },
    { id: "b2", text: "Notebook" },
  ],
};

export function request(url) {
  return new Promise((resolve, reject) => {
    if (url in SERVER) resolve(SERVER[url]);
    else reject(new Error("not found"));
  });
}

renderFeed(status) builds the screen’s tree from the request state. In the loading state there is an indicator under the title, in the data state a list, in the error state a message. The rows are keyed by id.

// feed.mjs — builds the order feed screen's view tree from the request state
import { el } from "./fate.mjs";

export function renderFeed(status) {
  let body;
  if (status.phase === "loading") {
    body = el("Spinner");
  } else if (status.phase === "error") {
    body = el("Notice", { text: status.message });
  } else {
    body = el("List", {}, status.rows.map((row) =>
      el("Row", { text: row.text }, [], row.id)));
  }
  return el("Feed", {}, [el("Title", { text: "Orders" }), body]);
}

First the request succeeds. The screen opens in the loading state and turns into the data state when the response arrives.

// main.mjs
import { reconcile, printOps, countFates } from "./fate.mjs";
import { renderFeed } from "./feed.mjs";
import { request } from "./request.mjs";

const loading = renderFeed({ phase: "loading" });
request("/orders")
  .then((rows) => renderFeed({ phase: "data", rows }))
  .catch(() => renderFeed({ phase: "error", message: "could not load" }))
  .then((next) => {
    const ops = reconcile(loading, next);
    printOps(ops);
    countFates(ops);
  });
kept: /Feed
kept: /Feed/Title
unmounted: /Feed/Spinner
mounted: /Feed/List
mounted: /Feed/List/Row[a1]
mounted: /Feed/List/Row[b2]
kept: 2
updated: 0
mounted: 3
unmounted: 1
commands: 4

When the data arrives, /Feed/Spinner is unmounted. The list and its two rows are mounted in its place. The title and the screen itself are kept. The command count is 4, and three of them are the nodes of the new list. Because the indicator and the list share the same place in the tree, the rule does not count them as two states of the same node: their types differ, one leaves entirely and the other is mounted entirely.

The error state is another outcome of the same transition. We repeat the same flow with an address the server does not know.

// main.mjs
import { reconcile, printOps, countFates } from "./fate.mjs";
import { renderFeed } from "./feed.mjs";
import { request } from "./request.mjs";

const loading = renderFeed({ phase: "loading" });
request("/missing")
  .then((rows) => renderFeed({ phase: "data", rows }))
  .catch(() => renderFeed({ phase: "error", message: "could not load" }))
  .then((next) => {
    const ops = reconcile(loading, next);
    printOps(ops);
    countFates(ops);
  });
kept: /Feed
kept: /Feed/Title
unmounted: /Feed/Spinner
mounted: /Feed/Notice
kept: 2
updated: 0
mounted: 1
unmounted: 1
commands: 2

The indicator is again unmounted, but the only node mounted in its place is the error message: commands 2. Every path out of the loading state unmounts the indicator. Which node will be mounted is decided by the result of the request.

The Offline Cache

An offline cache keeps the last successful response in the app’s memory or in local storage. If there is no network or the response is late, the screen shows the last known data. This changes the first state of the screen: if there is a cache, the screen opens not with a loading indicator but directly with the list.

// cache.mjs — offline cache: keeps the last successful response in memory
const store = new Map();

export function saveCache(url, rows) {
  store.set(url, rows);
}

export function readCache(url) {
  return store.has(url) ? store.get(url) : null;
}

We measure side by side the two situations in which the screen is opened for the first time: opening without a cache and opening with a cache. The tree does not exist yet, so the previous tree is null and every node is mounted.

// main.mjs
import { reconcile, countFates } from "./fate.mjs";
import { renderFeed } from "./feed.mjs";
import { saveCache, readCache } from "./cache.mjs";

saveCache("/orders", [
  { id: "a1", text: "Pen" },
  { id: "b2", text: "Notebook" },
]);
console.log("uncached:");
countFates(reconcile(null, renderFeed({ phase: "loading" })));
console.log("cached:");
countFates(reconcile(null, renderFeed({ phase: "data", rows: readCache("/orders") })));
uncached:
kept: 0
updated: 0
mounted: 3
unmounted: 0
commands: 3
cached:
kept: 0
updated: 0
mounted: 5
unmounted: 0
commands: 5

Opening with a cache mounts more nodes: five instead of three. But the screen never shows an indicator, so there is no indicator to be unmounted later. A cache means more work at launch and less work in the next transition: when the response arrives, the list’s nodes are not mounted again; they are compared where they stand.

Retrying After an Error

The error state is not the end of the road; the user often says “try again.” For the tree, a retry is a return: the error notice goes away and the loading indicator appears again. These two nodes replace each other under the root.

// main.mjs
import { reconcile, printOps, countFates } from "./fate.mjs";
import { renderFeed } from "./feed.mjs";

const ops = reconcile(
  renderFeed({ phase: "error", message: "could not load" }),
  renderFeed({ phase: "loading" }),
);
printOps(ops.filter((op) => op.fate !== "kept"));
countFates(ops);
unmounted: /Feed/Notice
mounted: /Feed/Spinner
kept: 2
updated: 0
mounted: 1
unmounted: 1
commands: 2

The root and the title are kept, because they have the same type and carry the same props in every state. The error notice is unmounted and the indicator is mounted; even though the two nodes are in the same position, their types differ, so the rule does not count one as the updated version of the other. This shows that the cost of a retry in the tree is constant: neither the length of the error message nor the number of the attempt changes the command count.

There is a subtle point here. If the retry fails, the screen returns to the error state and the notice is mounted again. With consecutive failed attempts, the user sees the indicator and the notice being mounted and unmounted continually. This is not a tree problem but a design question: keeping the old error notice while the retry is in progress calms the tree, but it does not tell the user that the attempt has ended. The rule says only how many commands each option is; it does not say which is better.

The Bounding Measurement

A value that comes from the cache may have become stale by the time the response arrives. In M30/K04 the question is whether a screen agrees with the model, and a stale screen is one outcome. The fate measured in this lesson does not measure agreement: a stale value can also sit in a kept node. If the new response is the same as the old value, the node is kept, whether the value is current or the response never arrives at all. The measurement below compares two situations. In one, the new response carries the same rows as the cache. In the other, the text of the second row has changed.

// main.mjs
import { reconcile, printOps, countFates } from "./fate.mjs";
import { renderFeed } from "./feed.mjs";

const cached = [
  { id: "a1", text: "Pen" },
  { id: "b2", text: "Notebook" },
];
const stale = renderFeed({ phase: "data", rows: cached });
const same = renderFeed({ phase: "data", rows: cached.map((row) => ({ ...row })) });
const changed = renderFeed({
  phase: "data",
  rows: [cached[0], { id: "b2", text: "Notebook 2" }],
});

console.log("same response:");
countFates(reconcile(stale, same));
console.log("changed response:");
printOps(reconcile(stale, changed).filter((op) => op.fate !== "kept"));
same response:
kept: 5
updated: 0
mounted: 0
unmounted: 0
commands: 0
changed response:
updated: /Feed/List/Row[b2]

In the same response all nodes are kept and commands is 0. In the changed response only Row[b2] is updated. In the first case the rule says “nothing happened,” but this does not mean the cache is current: if the response had never arrived, the same tree would have stayed kept in the same way. Stale and kept are not the same question. The first is whether the value matches the truth on the server, and it lies outside the tree. The second is what the update did to the node.

Common Misconceptions

The first misconception is the idea that “the loading indicator is hidden when the list arrives.” In terms of the tree, the indicator is not hidden; it leaves the tree: the node is unmounted and its native view is gone. If loading is requested again, the indicator is mounted anew, and no trace of the old one remains.

The second misconception is the idea that “a cache reduces network load, so it makes the screen lighter too.” Opening with a cache, as seen in the measurement, mounts more nodes. The gain is the disappearance of the indicator and of the waiting, not a lower command count.

The third misconception is to treat the error state as an exception. An error is one of the ordinary results of the request layer and an ordinary node in the tree. The screen knowing how to mount the error node ensures that no empty screen is left in the error state.

The fourth misconception is to assume that a retry is cheap and invisible in the tree. In the retry measurement the error notice is unmounted and the indicator is mounted: two commands. These two commands repeat with every failed attempt. If the attempts fail one after another, the screen, in the user’s eyes, goes back and forth between error and loading. In terms of the tree, every round trip is the same two commands; whether this feels tiring to the user is a question outside the measurement.

The fifth misconception is to treat the request and the screen as one thing. The request returns a promise and reaches one of three results; the screen turns this result into a tree. The request layer produces the error message, and the tree only carries this message as a prop of a node. If the two layers stay separate, the request layer can be tested without touching the screen, and the screen can be rendered without running the request.

The sixth misconception is to treat the offline cache as independent of data freshness. The list in the cache is the last successful response and may be older than the current state on the server. The tree does not see this: if the rows from the cache and the rows from the server have the same form, the rule does not tell the two apart. If we want to show the user the age of the data, we need to write it into the tree explicitly, for example by adding a separate node.

The seventh misconception is to treat the three states as independent screens. Loading, error, and data swap the body under the same root node; the root and the title are kept in all three states. For this reason the cost of a transition between states is measured not by the size of the screen but by the size of the body.

Summary

  • This lesson’s fate: unmounted. When data arrived, the /Feed/Spinner node came out unmounted because it gave its place to the list; the list and its rows are mounted.
  • In the error state too the indicator is unmounted and an error message is mounted in its place; every path out of the loading state unmounts the indicator.
  • Opening with a cache mounts more nodes but mounts no indicator; opening without a cache mounts the indicator and unmounts it later.
  • If the new response carries the same value as the cache, the rows are kept; the row that changed in the response is updated.
  • The fate does not measure agreement: a stale value can sit in a kept node.

Next Step

In this lesson the cache stayed in memory and disappeared when the app closed. Data that must persist needs local storage. The next lesson, Local Storage, measures how a draft read from storage at launch appears in the tree.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close