---
title: 'Application Architecture: Routing, State and Data'
source: 'https://academia.sh/en/courses/frontend-architecture'
language: en
updated: '2026-08-17T12:51:02+00:00'
license: 'CC BY-SA 4.0'
lessons: 22
outcomes:
    - 'Builds client-side routing with nested layouts'
    - 'Manages server state separately from client state'
    - 'Designs form validation and authentication flows securely'
---

# Application Architecture: Routing, State and Data

The layer above components: the routing that decides which screen appears, placing state according to its kind, fetching server data, and designing form and identity flows.

## Routing

Translating the address into a view: setting up the route matcher, the nested route tree and layout chain, parameter and query state, access tied to authorization, and route-based bundle splitting.

### Client-Side Routing

Source: https://academia.sh/en/courses/frontend-architecture/client-side-routing

The layer that translates the address into a view; the route table, resolving path patterns, sorting by specificity, parameter extraction, intercepting link clicks, and the responsibilities of the navigation cycle.

The previous course established the component and composition: a unit with inputs, its
own local state, that nests inside other components. Once the component tree is built,
which data flows where is clear. The question left open sits one layer up: **which
component stands at the root of the tree?** The North Slope Measurement Station
application is no longer a single screen. It now has a station list, a station detail
view, a measurement history, and a sign-in screen; something has to decide which one
appears.

The source of this decision is the address. The History and Location lesson in The
Browser and the Web Platform course established that the address can be split into
parts, that the history stack can be changed by the program, and that navigation can
happen without reloading the page. Routing is the decision layer that sits on top of
that mechanism: it takes the address and says which view gets built.

#### The Definition of Routing

Routing is the operation that maps an address to a view. Its input is the path portion
of the address; its output has two parts: the component to display and the parameters
to pass to that component.

The server side already does this work; every request is answered by looking at a path.
What is different about client-side routing is that the decision is made **without
reloading the document**. When the user clicks a row in the station list, no new
document is requested; the address changes, the matcher resolves the new path, and part
of the tree is replaced with the new component.

This is not only about a lower navigation cost. Because the document stays in place,
state held in memory survives: a half-filled form, an open panel, a downloaded data set
all persist across navigation. The cost is that work the browser once did on its own
becomes the page's responsibility — scroll position, focus management, request
cancellation, the tab title. Each is addressed in turn over this topic.

#### The Route Table

Routing's data is a **route table**: a list of mappings between a path pattern and a
view. A path pattern is made of static segments, parameter segments, and a wildcard.

`/station` is a static segment; it matches only itself. `/:id` is a parameter segment;
it matches any segment and binds the matched value to a name. `/*` is the wildcard; it
consumes every remaining segment and is usually bound to a not-found view.

There is a point where the table alone is not enough. The path `/station/new` matches two
patterns at once: the static `/station/new` and the parameterized `/station/:id`. Which
wins? The answer cannot be declaration order — then row position would carry meaning,
and the bug would surface silently the moment two developers disturbed that order. The
answer is **specificity**: the pattern accepting fewer paths is tried first.

```js
// route-matcher.mjs — resolving the path pattern, sorting by specificity, parameter extraction
const WEIGHT = { static: 3, parameter: 2, wildcard: 1 };

function resolvePattern(pattern) {
  const segments = pattern.split("/").filter(Boolean).map((p) =>
    p === "*" ? { type: "wildcard" }
      : p.startsWith(":") ? { type: "parameter", name: p.slice(1) }
        : { type: "static", value: p });
  return {
    pattern, segments,
    score: segments.map((p) => WEIGHT[p.type]),
    wildcard: segments.some((p) => p.type === "wildcard") ? 1 : 0,
  };
}

function compareSpecificity(a, b) {
  if (a.wildcard !== b.wildcard) return a.wildcard - b.wildcard;      // wildcard pattern goes last
  const common = Math.min(a.score.length, b.score.length);
  for (let i = 0; i < common; i++)                         // the first differing segment decides
    if (a.score[i] !== b.score[i]) return b.score[i] - a.score[i];
  return b.score.length - a.score.length;                   // longer pattern first
}

function match(route, path) {
  const segments = path.split("/").filter(Boolean);
  const params = {};
  for (let i = 0; i < route.segments.length; i++) {
    const p = route.segments[i];
    if (p.type === "wildcard") { params.rest = segments.slice(i).join("/"); return params; }
    if (i >= segments.length) return null;
    if (p.type === "static") { if (p.value !== segments[i]) return null; }
    else params[p.name] = decodeURIComponent(segments[i]);
  }
  return segments.length === route.segments.length ? params : null;
}

// Declaration order deliberately broken: ":id" is more general yet written before "new".
const declarationOrder = [
  "/station/:id",
  "/station/new",
  "/*",
  "/station/:id/measurements/:time",
  "/",
  "/station",
  "/station/:id/measurements",
];
const table = declarationOrder.map(resolvePattern).sort(compareSpecificity);

console.log("-- table sorted by specificity --");
for (const r of table) console.log((r.score.join(",") || "-").padEnd(9), r.pattern);

console.log("-- matches --");
for (const path of ["/", "/station", "/station/new", "/station/north-slope",
  "/station/north-slope/measurements", "/station/north-slope/measurements/2024-02-11T06:00",
  "/station/north%20slope", "/settings/privacy"]) {
  let found = null, params = null;
  for (const r of table) {
    const p = match(r, path);
    if (p) { found = r.pattern; params = p; break; }
  }
  console.log(path.padEnd(44), found.padEnd(30), JSON.stringify(params));
}
```

```
-- table sorted by specificity --
3,3       /station/new
3,2,3,2   /station/:id/measurements/:time
3,2,3     /station/:id/measurements
3,2       /station/:id
3         /station
-         /
1         /*
-- matches --
/                                            /                              {}
/station                                     /station                       {}
/station/new                                 /station/new                   {}
/station/north-slope                         /station/:id                   {"id":"north-slope"}
/station/north-slope/measurements            /station/:id/measurements      {"id":"north-slope"}
/station/north-slope/measurements/2024-02-11T06:00 /station/:id/measurements/:time {"id":"north-slope","time":"2024-02-11T06:00"}
/station/north%20slope                       /station/:id                   {"id":"north slope"}
/settings/privacy                            /*                             {"rest":"settings/privacy"}
```

The sort produces an order independent of declaration order. `/station/new` rises to
the top; the path `/station/north-slope` does not match it, so it falls to the
parameterized pattern and the ID is extracted correctly. Because the wildcard is pushed
to the end, it never shadows a real route.

There are three more details worth reading in the match table. The root path's pattern
contains no segments at all and matches only itself. A parameter's value is delivered
with the **escaping resolved** in the address: the segment `north%20slope` becomes
`north slope`; a matcher that skips this step delivers IDs containing spaces or
non-ASCII characters corrupted. An undefined path falls to the wildcard and carries what
was requested in the `rest` field; the not-found view can show this information to the
user.

One rule is set up implicitly: a parameter segment consumes **one** segment. The path
`/station/a/b` does not match the pattern `/station/:id`, because the segment counts
do not line up. If a value containing a slash needs to be carried, either the wildcard is
used, or the value is escaped to fit a single segment.

#### The Navigation Cycle

The matcher is a function; routing itself is a cycle that runs continuously. The cycle
has four steps, and each step carries a separate responsibility.

**The address changes.** This has three sources: the user clicking a link, the program
calling navigation, or the user pressing the back or forward button. In the first two,
code changes the address; in the third, the browser changes it and notifies the code.

**The matcher runs.** The new path is looked up in the route table, and the winning
pattern and its parameters are extracted.

**The view is built.** The winning route's component is placed in the tree, and the
parameters are passed to it.

**Edge responsibilities are carried out.** Scroll position is set, focus moves to the
new content, the tab title is updated, and the previous view's requests are canceled.

The fourth step is the part of the cycle most often skipped. In a real navigation, the
browser does all this on its own; in client-side navigation, none of it happens, since
the document never changes. Focus not moving is an especially quiet defect: someone
using a keyboard or a screen reader presses a link and does not notice the page changed,
because focus stays put.

#### Intercepting a Link

The starting point of client-side navigation is **canceling the link click's default
action**. The Default Behavior and Cancellation lesson in The Document and Events topic
established this mechanism: when an event is cancelable, the browser's own behavior can
be stopped.

Not every click is intercepted. The cases that must not be are well defined and tested
with a chain of conditions. A link to a different origin is not intercepted; the
application cannot display that address anyway. A download link, a link requested to
open in a new tab, and a link opened with a modifier key are also left alone. Actions
taken through a right-click context menu do not produce the event at all.

When these conditions are forgotten, the defect is always the same: the user loses a
familiar browser behavior. Not being able to open a link in a new tab, copy the address,
or use a download link all trace back to this single omission.

After the click is canceled, the work that follows is pushing a new entry onto the
history and starting the cycle. The back button runs the same cycle without pushing an
entry: the browser produces a navigation event, the code listens for it, and calls the
matcher with the new path.

#### The Address as the Single Source of Truth

Once routing is set up, an application can end up with two separate pieces of "which
screen are we on" information: the address and a local variable. Kept separate, the two
inevitably drift apart — the back button is pressed, the address changes, but the
variable keeps its old value, or the reverse happens.

The rule is this: **the active view is derived from the address, not held separately in
memory.** Going to a screen means changing the address, not setting a variable. The view
is the result of that change.

This rule gives a test. From any screen in the application, copying the address bar's
address and opening it in a new tab should bring up the same screen. If it does not, some
part of that screen was never written into the address.

The counterpart to this rule sits on the server side, established in the History and
Location lesson: every address the client produces must get a valid response when
requested directly. A server that maps defined path patterns to a single document
provides this; a genuinely missing address still returning a not-found response is part
of the same contract.

#### Summary

- Routing is the decision layer that maps the path portion of the address to a view and
  a set of parameters; on the client side, this decision is made without reloading the
  document.
- The route table is built from static, parameter, and wildcard segments; matching
  ambiguity is resolved by specificity order, not declaration order, and the wildcard is
  always pushed to the end.
- A parameter segment consumes a single segment, and its value is delivered with the
  escaping resolved.
- Navigation is a cycle: the address changes, the matcher runs, the view is built, and
  edge responsibilities such as scroll, focus, title, and cancellation are carried out.
- A link click's default action is canceled only in cases the application can genuinely
  take over; external addresses, downloads, and new-tab requests are left to the
  browser.
- The active view is derived from the address; holding the same information separately
  in memory produces two sources of truth that drift apart with the back button.

#### Next Step

This lesson's matcher worked on a flat table: each path bound to a single view. The
application's screens, though, share a common shell. The station detail view and the
measurement history show the same header, the same side navigation, and the same
station profile; only the middle region changes. With a flat table, this shared shell
gets rebuilt and repainted on every route, and the state inside it resets on every
navigation. The next lesson covers defining routes as a nested tree, producing a layout
chain from the matching branch, and why the shell is preserved during navigation.

### Nested Routes and Layouts

Source: https://academia.sh/en/courses/frontend-architecture/nested-routes-and-layouts

Defining routes as a tree; layout components and the outlet, the index route, the active layout chain produced from a match, the shell preserved during navigation, and handling parameter changes separately.

The previous lesson's matcher worked on a flat table: each path bound to a single view.
The North Slope Measurement Station application's screens, though, share a common
shell. The station detail view and the measurement history show the same header, the
same side navigation, and the same station profile; only the middle region changes.

With a flat table, this shared shell has to be rebuilt in every route's component. The
result is three separate defects: the same markup repeats in multiple places, the shell
repaints on every navigation, and state inside the shell — an open menu, a scrolled
list — resets on every navigation. This lesson solves all three by defining routes as a
tree.

#### The Route Tree

An address's path is already hierarchical. `/station/north-slope/measurements` is made
of three segments, and each one narrows down the one before it. The route definition
can be written the same way, nested: each node takes on one segment and hands the
remaining segments to the nodes below it.

Nodes in the tree serve two purposes. A **layout component** node builds the
surroundings for every view beneath it: header, navigation, frame. A leaf node fills in
the part of the screen that changes. A layout component declares where the child view
goes with an **outlet**. This is routing's counterpart to the slot concept defined in
the Templates and Slots lesson in the Web Components and Offline topic: the content
comes from outside, and the container says where to place it.

A node's own path is written **relative**. The `:id` node sits beneath the `station`
node, so its full path becomes `/station/:id`; the definition never repeats this
prefix. This way, when an entire subtree moves elsewhere, the definitions inside it
do not change.

A third kind of node is needed. When the path `/station` is requested, the
`StationShell` layout matches, but there is no child view to place in the outlet. An
**index route** fills this gap: a child with no segment of its own that matches once
the path is exhausted. The index route is the layout's default content.

#### From Match to Chain

Matching against the tree proceeds root to leaf, consuming segments along the way. The
result is not a single component but a **layout chain** running from root to leaf. On
screen, this chain nests inside out: each component hosts the next one in its own
outlet.

```js
// route-tree.mjs — producing the active layout chain from a nested route tree
const tree = {
  path: "", component: "Shell", children: [
    { index: true, component: "Welcome" },
    { path: "session", component: "SignIn" },
    {
      path: "station", component: "StationShell", children: [
        { index: true, component: "StationList" },
        { path: "new", component: "StationForm" },
        {
          path: ":id", component: "StationProfile", children: [
            { index: true, component: "StationSummary" },
            { path: "measurements", component: "MeasurementHistory" },
            { path: "settings", component: "StationSettings" },
          ],
        },
      ],
    },
    { path: "*", component: "NotFound" },
  ],
};

const ORDER = (d) => d.index ? 0 : d.path === "*" ? 3 : d.path.startsWith(":") ? 2 : 1;

function resolve(node, remaining, chain, params) {
  if (node.index) return remaining.length === 0
    ? { chain: [...chain, node.component], params } : null;

  let consumed = 0, p = params;
  if (node.path === "*") { p = { ...p, rest: remaining.join("/") }; consumed = remaining.length; }
  else if (node.path !== "") {
    if (remaining.length === 0) return null;
    if (node.path.startsWith(":")) p = { ...p, [node.path.slice(1)]: decodeURIComponent(remaining[0]) };
    else if (node.path !== remaining[0]) return null;
    consumed = 1;
  }

  const newChain = [...chain, node.component];
  const newRemaining = remaining.slice(consumed);
  for (const child of [...(node.children ?? [])].sort((a, b) => ORDER(a) - ORDER(b))) {
    const result = resolve(child, newRemaining, newChain, p);
    if (result) return result;
  }
  return (node.children ?? []).length === 0 && newRemaining.length === 0
    ? { chain: newChain, params: p } : null;
}

const match = (path) => resolve(tree, path.split("/").filter(Boolean), [], {});

const paths = ["/", "/session", "/station", "/station/new", "/station/north-slope",
  "/station/north-slope/measurements", "/station/north-slope/settings", "/report/2024"];

console.log("-- active layout chain --");
for (const path of paths) {
  const { chain, params } = match(path);
  console.log(path.padEnd(30), chain.join(" > ").padEnd(52), JSON.stringify(params));
}

console.log("-- preserved and changed during navigation --");
const transitions = [
  ["/station", "/station/north-slope"],
  ["/station/north-slope", "/station/north-slope/measurements"],
  ["/station/north-slope/measurements", "/station/north-slope/settings"],
  ["/station/north-slope/measurements", "/station/east-ridge/measurements"],
  ["/station/north-slope/measurements", "/session"],
];
for (const [previous, next] of transitions) {
  const a = match(previous), b = match(next);
  let common = 0;
  while (common < a.chain.length && common < b.chain.length &&
    a.chain[common] === b.chain[common]) common += 1;
  const paramsEqual = JSON.stringify(a.params) === JSON.stringify(b.params);
  console.log(
    `${previous} -> ${next}`.padEnd(58),
    "preserved:", String(common),
    "| changed:", (b.chain.slice(common).join(" > ") || "-").padEnd(24),
    "| params equal:", paramsEqual,
  );
}
```

```
-- active layout chain --
/                              Shell > Welcome                                      {}
/session                       Shell > SignIn                                       {}
/station                       Shell > StationShell > StationList                   {}
/station/new                   Shell > StationShell > StationForm                   {}
/station/north-slope           Shell > StationShell > StationProfile > StationSummary {"id":"north-slope"}
/station/north-slope/measurements Shell > StationShell > StationProfile > MeasurementHistory {"id":"north-slope"}
/station/north-slope/settings  Shell > StationShell > StationProfile > StationSettings {"id":"north-slope"}
/report/2024                   Shell > NotFound                                     {"rest":"report/2024"}
-- preserved and changed during navigation --
/station -> /station/north-slope                           preserved: 2 | changed: StationProfile > StationSummary | params equal: false
/station/north-slope -> /station/north-slope/measurements  preserved: 3 | changed: MeasurementHistory       | params equal: true
/station/north-slope/measurements -> /station/north-slope/settings preserved: 3 | changed: StationSettings          | params equal: true
/station/north-slope/measurements -> /station/east-ridge/measurements preserved: 4 | changed: -                        | params equal: false
/station/north-slope/measurements -> /session              preserved: 1 | changed: SignIn                   | params equal: false
```

The root node's own path is empty; it consumes no segment but still stands at the head
of the chain. This is the definition of the shell that wraps the entire application. A
parameter is extracted at a single node in the tree and reaches every view beneath that
node with the same value: neither the measurement history nor the station settings
resolves the `id` value again.

#### What is Preserved During Navigation

The second part of the output shows the real payoff of nested routing in numbers. The
common prefix of two chains is the set of components that **stay in place** during
navigation; everything after it changes.

Moving from the measurement history to the station settings preserves three components.
The shell, the station shell, and the station profile stay in place in the tree; only
the innermost outlet fills with new content. Because they are not repainted, the state
inside them keeps living too: an open side navigation stays open, and the profile's
data is not requested again.

Moving to the sign-in screen drops the preserved count to one. The rest of the chain is
torn down; the local state of the torn-down components is lost. This is not a side
effect of the nested route definition — it is the decision the definition directly
expresses: the shape of the tree determines which part survives which navigations.

The fourth row shows a trap all on its own. Moving from the `north-slope` station to
the `east-ridge` station leaves the layout chain **exactly the same** — all four
components are preserved — but the parameter has changed. Because the components stay
in place, they do not get rebuilt; the screen keeps showing the previous station's data.
The correct behavior is for parameter-dependent data to also listen for the parameter
changing. This is routing's counterpart to the dependency rule from the Side Effects
lesson in the Component-Based Interface Development course: a data request's
dependency is not the component's existence, it is the parameter's value.

#### Where the Not-Found Route Sits

Wherever the wildcard node sits in the tree, the not-found view appears inside that
level's shell. In the example, the wildcard is a child of the root; requesting
`/report/2024` produces a chain made of the shell and the not-found view, so the user
keeps seeing the top header and navigation.

Had the wildcard been placed under the `station` node instead, the path
`/station/north-slope/unknown` would show an error inside the station shell; the user
would not lose the station context. Both are valid designs, and the choice depends on
where the user should be able to return to. The only wrong choice is never defining the
wildcard at all: then an undefined address produces a blank screen.

The server-side counterpart should not be forgotten. The client rendering a not-found
view does not mean the server returns a successful response for that address; per the
rule established in the History and Location lesson, a genuinely missing address must
still get a not-found status code.

#### Pathless Layout Nodes

Not every layout in the tree has to consume a segment. A node with an empty path of its
own adds only a frame, without changing the address. This has two uses.

The first is visual grouping: if several routes share the same narrow-column layout,
that layout is lifted to a common parent node without adding a new segment to the
address hierarchy. The address the user sees stays simple.

The second is behavior sharing: routes that share the same access condition or the same
data load are grouped under a pathless node. How the access condition gets enforced at
these nodes is the subject of the next two lessons.

There is one more option in the opposite direction: a node that consumes a segment but
has no view of its own. This node deepens the address hierarchy without adding anything
to the screen; the child view goes straight into the parent's outlet.

#### Summary

- The route tree turns the address's hierarchy into the route definition's hierarchy;
  each node takes on one segment and hands the remaining path to its children.
- A layout component hosts the child view in an outlet; the index route supplies the
  layout's default content once the path is exhausted.
- A match's output is not a single component but a layout chain running from root to
  leaf; a parameter is extracted once in the tree and reaches every view beneath it.
- During navigation, the common prefix of two chains stays in place and the state
  inside it is preserved; the part that diverges is torn down and rebuilt.
- When the chain stays the same and only the parameter changes, the components are not
  rebuilt; parameter-dependent data has to track that change as its own dependency.
- The wildcard node's level in the tree determines which shell the not-found view
  appears inside.

#### Next Step

The tree delivered the state carried by the path — which station, which section — to
the components. The rest of the screen's state, though, is not in the address yet. The
measurement history page might have a date range selected, a measurement type
filtered, the list on its second page, and sorted descending by date. When these live
in the component's local variables, the user cannot share that view, bookmark it, or
return to the previous filter with the back button. The next lesson covers which state
goes into the path and which into the query, how query state is serialized and parsed
back, and how default values are left out without cluttering the address.

### Route Parameters and Query

Source: https://academia.sh/en/courses/frontend-architecture/route-parameters-and-query

Carrying state in the address; separating the path parameter from query state, reading and validating through a schema, omitting defaults from the address, canonical form, resetting dependent parameters, and the history entry decision.

The route tree delivered the state carried by the path to the components: which
station, which section. The rest of the measurement history screen's state, though,
is not in the address yet. A date range might be selected, a measurement type filtered,
the list on its second page, sorted descending by date.

When these live in the component's local variables, the user cannot share that view,
bookmark it, or return to the previous filter with the back button. Written into the
address, though, the address grows long, gets cluttered, and every change becomes a
candidate for pushing a new history entry. This lesson separates two questions: which
state enters the address, and how the state that enters gets written.

#### Path, Query, or Memory

The History and Location lesson separated the address's parts by the kind of state they
carry: the path carries the resource's identity, the query carries which view of that
same resource is requested, and the fragment carries a position within the document.
Routing turns this distinction into an actionable rule.

A **path parameter** selects a resource. The station ID belongs in the path, because
when it changes, what is displayed is a different object. A path parameter has no
default: the resource is either specified or it is not.

A **query parameter** adjusts the presentation of the same resource. Filters, sorting,
the page number, and the display format belong here. Each one has a default, and the
screen still makes sense when the user has selected none of them.

**Memory**, meanwhile, holds state that has no place in the address. The History and
Location lesson supplied the test: does the user expect to see the same screen when
this address is opened in a new tab? An open tooltip, a slider's momentary value while
being dragged, an animation's progress all fail this test.

There is a borderline case: information meaningful only for this session but that needs
to survive across navigation — the last row viewed in a list, for instance. This
belongs in the history entry's state object; it is not written to the address, but it
comes back when the user navigates back.

#### The Path Parameter Is External Input

A path parameter is a field the user can write into. It can be typed into the address
bar by hand, come from an old bookmark, or have been generated incorrectly in a link.
The matcher having extracted it does not mean it is valid.

This holds on the security side too. A user who can open `/station/north-slope` can
just as well open `/station/east-ridge`. The client validating the parameter catches
format errors; only the server can validate authorization. This distinction is the
subject of the next lesson.

Validation has three distinguishable outcomes. A malformed parameter — a letter where a
number was expected — falls to the not-found view. A well-formed parameter with no
matching resource is discovered through the server's response and also produces
not-found. A valid parameter is passed on to the view.

#### The Schema of Query State

The query string is a string map: every value is text, every name can appear more than
once, and its content comes from outside. These three properties make it impossible to
use the query directly as state. A **schema** sits between them: a definition that
declares each parameter's type, default, and valid value set.

```js
// query-state.mjs — reading the query string through a schema and writing it in canonical form
const SCHEMA = {
  measurement: { type: "choice", default: "all", options: ["all", "temperature", "humidity", "wind"] },
  threshold: { type: "number", default: null },
  page: { type: "integer", default: 1, min: 1 },
  sort: { type: "choice", default: "descending", options: ["ascending", "descending"] },
  tag: { type: "multi", default: [] },
};

// Single validation point: both reading and writing pass through this.
function validate(rule, raw) {
  if (rule.type === "choice")
    return rule.options.includes(raw) ? { value: raw } : { invalid: true };
  const n = rule.type === "integer" ? Number.parseInt(raw, 10) : Number(raw);
  if (!Number.isFinite(n) || (rule.min !== undefined && n < rule.min)) return { invalid: true };
  return { value: n };
}

function read(query) {
  const p = new URLSearchParams(query);
  const state = {}, warnings = [];
  for (const [name, rule] of Object.entries(SCHEMA)) {
    if (rule.type === "multi") {
      const all = p.getAll(name);
      state[name] = all.length ? [...new Set(all)].sort() : rule.default;
      continue;
    }
    const raw = p.get(name);
    if (raw === null) { state[name] = rule.default; continue; }
    const result = validate(rule, raw);
    if (result.invalid) { state[name] = rule.default; warnings.push(`${name}=${raw} invalid`); }
    else state[name] = result.value;
  }
  return { state, warnings };
}

function write(state) {
  const p = new URLSearchParams();
  for (const [name, rule] of Object.entries(SCHEMA)) {     // schema order gives the canonical order
    const value = state[name];
    if (rule.type === "multi") {
      for (const d of [...new Set(value ?? [])].sort()) p.append(name, d);
      continue;
    }
    if (value === null || value === undefined || value === rule.default) continue;
    if (validate(rule, String(value)).invalid) continue;   // invalid value is not written to the address
    p.set(name, String(value));
  }
  const s = p.toString();
  return s ? `?${s}` : "";
}

console.log("-- reading --");
for (const query of [
  "",
  "?measurement=temperature&page=3",
  "?page=abc&measurement=snow&threshold=-3.5&tag=night&tag=snow&tag=night",
  "?page=0&sort=ascending&measurement=relative+humidity",
]) {
  const { state, warnings } = read(query);
  console.log((query || "(empty)").padEnd(66), JSON.stringify(state));
  if (warnings.length) console.log(" ".repeat(66), "warnings:", warnings.join("; "));
}

console.log("-- writing --");
for (const state of [
  { measurement: "all", threshold: null, page: 1, sort: "descending", tag: [] },
  { measurement: "temperature", threshold: -5, page: 1, sort: "descending", tag: [] },
  { measurement: "relative humidity", threshold: null, page: 4, sort: "ascending", tag: ["snow", "night"] },
]) console.log(JSON.stringify(state).padEnd(94), write(state) || "(empty)");

console.log("-- round trip --");
const initial = { measurement: "wind", threshold: 12.5, page: 2, sort: "ascending", tag: ["night"] };
const str = write(initial);
console.log("string  ", str);
console.log("same     ", JSON.stringify(read(str).state) === JSON.stringify(initial));
console.log("stable   ", write(read(str).state) === str);

console.log("-- on the address --");
const address = new URL("https://example.test/station/north-slope/measurements?page=9&measurement=humidity");
const updated = { ...read(address.search).state, measurement: "temperature", page: 1 };
console.log(address.pathname + write(updated));
```

```
-- reading --
(empty)                                                            {"measurement":"all","threshold":null,"page":1,"sort":"descending","tag":[]}
?measurement=temperature&page=3                                    {"measurement":"temperature","threshold":null,"page":3,"sort":"descending","tag":[]}
?page=abc&measurement=snow&threshold=-3.5&tag=night&tag=snow&tag=night {"measurement":"all","threshold":-3.5,"page":1,"sort":"descending","tag":["night","snow"]}
                                                                   warnings: measurement=snow invalid; page=abc invalid
?page=0&sort=ascending&measurement=relative+humidity               {"measurement":"all","threshold":null,"page":1,"sort":"ascending","tag":[]}
                                                                   warnings: measurement=relative humidity invalid; page=0 invalid
-- writing --
{"measurement":"all","threshold":null,"page":1,"sort":"descending","tag":[]}                   (empty)
{"measurement":"temperature","threshold":-5,"page":1,"sort":"descending","tag":[]}             ?measurement=temperature&threshold=-5
{"measurement":"relative humidity","threshold":null,"page":4,"sort":"ascending","tag":["snow","night"]} ?page=4&sort=ascending&tag=night&tag=snow
-- round trip --
string   ?measurement=wind&threshold=12.5&page=2&sort=ascending&tag=night
same      true
stable    true
-- on the address --
/station/north-slope/measurements?measurement=temperature
```

The reading side does not fail on any input. A missing parameter falls to its default, a
malformed number falls to its default and produces a warning, an undefined option is
rejected. This behavior is called **robustness**: an old address a user still has lying
around opens a meaningful view instead of dropping the application into an error
screen.

Two decisions show up on the writing side. A value equal to the default is **not
written to the address**; in the first row, every field is a default, so the query is
empty. This does more than keep the address short: the same screen is always referred
to by a single address. An invalid value is not written either; in the third row, the
undefined measurement type is dropped and the remaining fields are written.

#### Canonical Form

The same state having exactly one string representation is called **canonical form**.
The writer in the example achieves this with two rules: parameters are written in
schema order, and multi-valued sets are sorted within themselves. This is why the tag
set `snow, night` shows up in the output in the order `night, snow`.

The round-trip section tests two properties. Reading is the inverse of writing: state
that is written, when read back, produces the same object. Writing is stable: state
that is read back, when written again, produces the same string. Together, the two say
that the address is a reliable carrier of state.

Canonical form pays off in three places. A server state cache can use the address as
its key; because the same view is never referred to by two different strings, no
duplicate cache entries appear. Whether two addresses show the same screen can be
determined by a plain string comparison. And addresses shared by different users end up
identical.

#### Dependent Parameters

The final section is an example of a decision. With page nine and the humidity filter
in the address, the measurement type is switched to temperature; the output carries no
page number, because it was explicitly reset to one.

When a filter changes, the old page number loses its meaning: page nine might not exist
under the new filter, and the user sees an empty list. The same dependency holds for a
selected row's ID, an infinite list's loaded length, and a position within a sort.
These resets do not happen on their own; which parameter invalidates which is written
down explicitly.

The reverse direction matters just as much: **an unrelated parameter is preserved**.
While the measurement type changes, the sort and the threshold must stay in place. Code
that concatenates the query string by hand loses this protection; code that reads the
state as a whole and writes over it keeps it.

#### Pushing a History Entry or Replacing One

Every change to query state changes the address; not every change, though, has to push
a new entry onto the history. The distinction established in the History and Location
lesson turns into a decision here.

**Pushed:** changes the user would want to return to with the back button. Moving
between pages, changing the measurement type, reversing the sort.

**Replaced:** intermediate states and corrections the user does not expect to return to.
A search field that pushes an entry for every character typed turns the back button
into a mechanism that undoes typing letter by letter. Fields like this replace during
typing; combined with the debounce from the Form Events lesson, the address updates
once after typing stops.

The correction made at the application's startup also uses replace: if the address
holds an invalid value, it is pulled into canonical form, but this correction leaves no
history entry. If it did, the back button would take the user back to the invalid
address, and a loop would form.

#### Summary

- A path parameter selects a resource and has no default; a query parameter adjusts
  the same resource's presentation and each one has a default; the rest of the state
  stays in memory or in the history entry's state object.
- The path parameter is external input; format validation happens on the client, but
  authorization validation cannot be left to the client. Query state is likewise read
  through a schema: missing, malformed, and undefined values fall to their defaults
  instead of sending the application to an error screen.
- Values equal to the default and invalid values are not written to the address; fixing
  the parameter order and the order of multi-valued sets produces canonical form.
- Canonical form makes it possible to produce a cache key, compare two views, and keep
  shared addresses identical.
- If one parameter invalidates another, the reset is written down explicitly; unrelated
  parameters are preserved.
- Changes the user expects to return to are pushed onto the history; intermediate
  states and startup corrections replace the current entry.

#### Next Step

The address now carries, in full, which view of which resource is requested. The one
thing it does not carry is whether the user **has the right** to see that resource. The
station settings screen is open only to authorized users; measurement entry only to
signed-in users. A path typed by hand into the address bar tries to open these screens
too. The next lesson covers access conditions tied to a route, turning a condition into
a decision, returning to the requested address after signing in, and why client-side
enforcement is not a security boundary.

### Route Guards

Source: https://academia.sh/en/courses/frontend-architecture/route-guards

Access tied to authorization; route guards inherited through the route tree, allow–redirect–hold decisions, handling the unknown-identity state separately, validating the return address, and the limits of client-side enforcement.

The address now carries, in full, which view of which resource is requested. The one
thing it does not carry is whether the user has the right to see that resource. In the
North Slope Measurement Station application, the station settings screen is open only
to authorized users, and measurement entry only to signed-in users.

Hiding the link is not a solution: a path typed by hand into the address bar reaches the
matcher too. The decision has to be made between the match and the view being built.
This lesson covers that decision point, the kinds of decisions, and what a decision
made on the client side does and does not guarantee.

#### The Route Guard

A **route guard** is a function that runs before a route's view is built and returns
one of three answers. Its input is the matched route and the current identity; its
output is a decision.

**Allow** does not stand in the way of the view being built. **Redirect** turns the
navigation toward a different address. **Hold** says the decision cannot be made yet;
until the identity arrives, neither the target view nor the redirect is built.

Guards are **inherited** through the route tree. A guard placed on a node applies to
every route beneath it. This was the second use of pathless layout nodes from the
Nested Routes and Layouts lesson: routes sharing the same access condition are grouped
under a single parent node, and the condition is written once.

A route's guards form a chain ordered from root to leaf and run in that order. **The
first one to reject makes the decision**; the remaining guards do not run. The order
is not arbitrary: the session condition comes before the role condition, because knowing
who the user is has to happen before their role can be asked about.

```js
// route-guard.mjs — inherited guard chain, decision types, and validating the return address
const GUARD = {
  session: (u) => u.state === "unknown" ? { decision: "hold" }
    : u.state === "open" ? { decision: "allow" }
      : { decision: "redirect", target: "/session" },
  admin: (u) => u.role === "admin" ? { decision: "allow" }
    : { decision: "redirect", target: "/unauthorized", replaceEntry: true },
  anonymous: (u) => u.state === "open" ? { decision: "redirect", target: "/station", replaceEntry: true }
    : { decision: "allow" },
};

// Guards are inherited in the route tree: the one on a node applies to its whole subtree.
const TREE = [
  { path: "/", guards: [] },
  { path: "/session", guards: ["anonymous"] },
  { path: "/station", guards: ["session"] },
  { path: "/station/:id/measurements", guards: ["session"] },
  { path: "/station/:id/settings", guards: ["session", "admin"] },
];

const BASE = "https://example.test";

function evaluate(route, user, requestedAddress) {
  for (const name of route.guards) {
    const result = GUARD[name](user);
    if (result.decision === "allow") continue;
    if (result.decision === "hold") return { decision: "hold", guard: name };
    const target = result.target === "/session"
      ? `/session?return=${encodeURIComponent(requestedAddress)}`
      : result.target;
    return { decision: "redirect", guard: name, target, replaceEntry: result.replaceEntry ?? true };
  }
  return { decision: "allow" };
}

const users = [
  { name: "loading", state: "unknown", role: null },
  { name: "guest", state: "closed", role: null },
  { name: "observer", state: "open", role: "observer" },
  { name: "admin", state: "open", role: "admin" },
];

console.log("-- decision table --");
for (const u of users) {
  for (const route of TREE) {
    const requested = route.path.replace(":id", "north-slope");
    const r = evaluate(route, u, requested);
    console.log([
      u.name.padEnd(11), requested.padEnd(30), r.decision.padEnd(10),
      (r.target ?? "-").padEnd(48), r.guard ? `(${r.guard})` : "",
    ].join(" ").trimEnd());
  }
}

// The return address comes from the user; it is validated to prevent an open redirect.
function resolveReturn(raw) {
  if (raw === null) return "/station";
  let parsed;
  try { parsed = new URL(raw, BASE); } catch { return "/station"; }
  if (parsed.origin !== BASE) return "/station";
  if (!raw.startsWith("/") || raw.startsWith("//")) return "/station";
  return parsed.pathname + parsed.search;
}

console.log("-- validating the return address --");
for (const raw of [
  "/station/north-slope/settings?measurement=humidity",
  "https://evil.example/collect",
  "//evil.example/collect",
  "station/north-slope",
  "/station/../../etc",
  null,
]) console.log(JSON.stringify(raw).padEnd(40), "->", resolveReturn(raw));
```

```
-- decision table --
loading     /                              allow      -
loading     /session                       allow      -
loading     /station                       hold       -                                                (session)
loading     /station/north-slope/measurements hold       -                                                (session)
loading     /station/north-slope/settings  hold       -                                                (session)
guest       /                              allow      -
guest       /session                       allow      -
guest       /station                       redirect   /session?return=%2Fstation                       (session)
guest       /station/north-slope/measurements redirect   /session?return=%2Fstation%2Fnorth-slope%2Fmeasurements (session)
guest       /station/north-slope/settings  redirect   /session?return=%2Fstation%2Fnorth-slope%2Fsettings (session)
observer    /                              allow      -
observer    /session                       redirect   /station                                         (anonymous)
observer    /station                       allow      -
observer    /station/north-slope/measurements allow      -
observer    /station/north-slope/settings  redirect   /unauthorized                                    (admin)
admin       /                              allow      -
admin       /session                       redirect   /station                                         (anonymous)
admin       /station                       allow      -
admin       /station/north-slope/measurements allow      -
admin       /station/north-slope/settings  allow      -
-- validating the return address --
"/station/north-slope/settings?measurement=humidity" -> /station/north-slope/settings?measurement=humidity
"https://evil.example/collect"           -> /station
"//evil.example/collect"                 -> /station
"station/north-slope"                    -> /station
"/station/../../etc"                     -> /etc
null                                     -> /station
```

Every row in the table is a pair of decisions: who, and where to. The observer sees the
measurement history but cannot get into settings; the admin gets into both. The root
path and the sign-in screen carry no guard or an inverse guard.

#### The Inverse Guard

The `anonymous` guard does the opposite of the others: it moves a user whose session is
**open** away from the sign-in screen. In an application that skips this, a signed-in
user returns to the sign-in screen with the back button and does not know what to do
there; if they try to sign in again, the identity flow runs twice.

The same inverse logic applies to registration, password reset, and welcome flows.

#### Unknown Identity

The third kind of decision shows up in the first five rows of the output. When the
application first loads, whether the session is open is not known yet: this information
either arrives from the server through a request, or gets read from a local record.
Both take time.

The most common mistake made during this interval is **treating unknown as
unauthorized**. The outcome is familiar: the user reloads a protected page, sees the
sign-in screen for a moment, then returns to the actual page once the identity arrives.
The screen changing twice like this is not just uncomfortable; the address may have
changed twice too, leaving an unnecessary history entry.

The correct behavior is to defer the decision. Routing that receives a hold answer
builds neither the target view nor the sign-in screen until identity arrives; it leaves
a loading indicator in place instead. Once identity arrives, the guard runs again and
the real decision is made.

Holding must have a limit. If the identity request fails, the decision cannot be
deferred forever; failure is tied to the same outcome as being unauthorized, and the
user is told why.

#### The Return Address

When a guest user goes to a protected address, they are sent to the sign-in screen.
Landing on the welcome screen after signing in would mean forgetting where they wanted
to go. So the requested address is written into the redirect's query and read back
from there once the session opens.

This parameter is under the user's control. An attacker can put their own address in
the `return` value and share the link; the user signs in on a site they recognize and
is immediately sent somewhere else. This flaw is called an **open redirect**, and its
prevention fits in one sentence: the return address is validated as belonging to the
application's own origin before it is used.

The final part of the output shows what the validation catches. A fully qualified
external address is rejected. A protocol-relative address starting with a slash — the
form that sends the browser to an external host on the same protocol — is rejected too;
this is the case a check that only tests the first character misses. A relative address
is also rejected, because which base it resolves against is ambiguous. What is left is
only in-application paths, and these get normalized as they are resolved.

The same test applies everywhere an address is taken from the user: target addresses
carried in a query, notification links, return buttons on an error page.

#### How the Redirect Is Written to History

The redirect a guard produces is not a navigation the user **asked for**. So it does not
push a new history entry; it replaces the current one. Otherwise, when the user presses
back from the sign-in screen, they would return to the protected address, the guard would
run again, and it would send them back to the sign-in screen — the back button would
stop working.

This is why the decisions in the example carry a `replaceEntry` flag. The distinction
established in the History and Location lesson applies directly here: states the user
would want to return to are pushed, intermediate addresses are replaced.

#### The Limits of Client-Side Enforcement

Everything this lesson has set up is an **interface decision**, not a security
boundary. Three reasons show this.

The guard's code runs on the user's machine and can be read; role names, conditions,
and hidden routes' paths sit inside the bundle. The user can choose not to run the
guard: requesting the address directly, crafting the request by hand, or modifying the
response is within their control. The data a protected view uses is also fetched
through a request; hiding the view does not hide the data.

This is why **every protected operation is re-authorized on the server**. Client-side
enforcement keeps an unauthorized user from running into a blank screen or a stack of
errors; it does not protect the data.

The same distinction applies to hidden information. Hiding a menu item based on role
information is acceptable; but data only an admin should see cannot be sent to the
client tucked inside a hidden component. Everything sent can be seen.

#### Summary

- A route guard runs between the match and the view being built, and gives one of
  three decisions — allow, redirect, or hold; guards are inherited through the route
  tree, run in order as a chain, and the first one to reject makes the decision.
- The state where identity is not known yet is separated from being unauthorized; the
  decision is deferred, but the deferral ends once it fails.
- The inverse guard moves a user with an open session away from the sign-in and
  registration screens.
- The return address is user input; using it without validating that it belongs to the
  application's own origin produces an open redirect flaw.
- The redirect a guard produces does not push a history entry; it replaces the current
  one.
- Client-side enforcement is an interface decision; every protected operation and every
  piece of protected data is re-authorized on the server.

#### Next Step

Routing now knows which screen is open to whom. What it does not know is when that
screen's code gets downloaded. Right now, every screen in the application — the station
list, the settings form, the measurement chart, the sign-in flow — comes down together
in a single bundle. Even a user who came only to sign in downloads the admin settings
screen's code. The route tree already carries the information needed to make this
split: which component is used on which path is written into the definition. The next
lesson covers splitting the bundle along route boundaries, avoiding duplication of
shared parts, and the waiting and error states that splitting introduces.

### Code Splitting and Lazy Loading

Source: https://academia.sh/en/courses/frontend-architecture/code-splitting-and-lazy-loading

Splitting the bundle along route boundaries; the dependency closure, forming chunks by usage signature, measuring the initial load cost, the extra download per navigation, prefetching, and the waiting and error states splitting introduces.

Routing now knows which screen is open to whom. What it does not know is when that
screen's code gets downloaded. Every screen in the North Slope Measurement Station
application comes down together in a single bundle: a user who came only to sign in
also downloads the measurement chart's drawing library and the station map.

The route definition already carries the information needed to make this split. Which
component is used on which path is written into the tree; which path the user is on is
the one thing known at runtime. **Code splitting** is the work of combining these two
pieces of information and dividing the bundle along route boundaries.

#### The Dependency Closure

What a route actually needs is not limited to the modules it uses directly. Modules
depend on each other: the measurement chart uses the drawing module, and that in turn
uses date formatting. A route's cost is the sum of every module reachable from it in
the dependency graph. This set is called the **dependency closure**, and it is computed
with the graph traversal from the Data Structures course.

Once closures are derived, a second question follows: if a module is used in more than
one route, which chunk does it go into? Copied into every route's chunk, the same code
downloads more than once. Placed into a single shared chunk, routes that do not want it
are forced to download it anyway.

The solution is grouping modules by **usage signature**: a module's signature is the
set of routes that request it. Modules sharing the same signature download together,
because they are always needed together.

```js
// code-splitting.mjs — route dependency closure, signature-based chunking, and cost
const MODULE = {                       // size: transferred kB, deps: directly called modules
  "core": { size: 38, deps: ["design-system"] },
  "design-system": { size: 24, deps: [] },
  "table": { size: 18, deps: ["design-system"] },
  "chart": { size: 96, deps: ["design-system", "date-formatting"] },
  "map": { size: 120, deps: ["design-system"] },
  "form-validation": { size: 15, deps: ["design-system"] },
  "date-formatting": { size: 12, deps: [] },
  "export": { size: 30, deps: [] },
};

// Each route: the list of modules needed along its own layout chain.
const ROUTE = {
  "/session": ["form-validation"],
  "/station": ["table", "map"],
  "/station/:id": ["table", "date-formatting"],
  "/station/:id/measurements": ["chart", "table", "export"],
  "/station/:id/settings": ["form-validation"],
};

const ENTRY = ["core"];           // entry point downloaded on every visit

function closure(roots) {            // every module reachable in the dependency graph
  const seen = new Set(), stack = [...roots];
  while (stack.length) {
    const m = stack.pop();
    if (seen.has(m)) continue;
    seen.add(m);
    stack.push(...MODULE[m].deps);
  }
  return seen;
}

const total = (set) => [...set].reduce((t, m) => t + MODULE[m].size, 0);

const entryClosure = closure(ENTRY);
const routeClosure = Object.fromEntries(
  Object.entries(ROUTE).map(([route, k]) => [route, closure(k)]));

// Usage signature: the set of routes that request a module. Same signature = same chunk.
const signature = new Map();
for (const [module] of Object.entries(MODULE)) {
  if (entryClosure.has(module)) continue;                 // already in the entry chunk
  const requesters = Object.keys(ROUTE).filter((y) => routeClosure[y].has(module));
  if (requesters.length === 0) continue;
  const key = requesters.join("+");
  if (!signature.has(key)) signature.set(key, []);
  signature.get(key).push(module);
}

console.log("-- chunks --");
console.log(`entry${" ".repeat(3)}${String(total(entryClosure)).padStart(4)} kB  ${[...entryClosure].sort().join(", ")}`);
const chunkName = new Map();
let n = 0;
for (const [key, modules] of [...signature].sort((a, b) => b[0].split("+").length - a[0].split("+").length)) {
  const name = `chunk-${++n}`;
  chunkName.set(key, name);
  console.log(`${name} ${String(modules.reduce((t, m) => t + MODULE[m].size, 0)).padStart(4)} kB  ${modules.sort().join(", ")}`);
  console.log(`${" ".repeat(12)}requested by: ${key.split("+").join("  ")}`);
}

const singleBundle = total(new Set([...entryClosure, ...Object.values(routeClosure).flatMap((s) => [...s])]));
const split = (route) => total(new Set([...entryClosure, ...routeClosure[route]]));

console.log("-- initial load cost --");
console.log("route".padEnd(26), "single bundle", "split", "gain");
for (const route of Object.keys(ROUTE)) {
  const b = split(route);
  console.log(route.padEnd(26), String(singleBundle).padStart(9), String(b).padStart(9),
    `${String(Math.round((1 - b / singleBundle) * 100)).padStart(5)}%`);
}

console.log("-- extra download per navigation --");
for (const [previous, next] of [
  ["/station", "/station/:id"],
  ["/station/:id", "/station/:id/measurements"],
  ["/station/:id/measurements", "/station/:id/settings"],
  ["/session", "/station"],
]) {
  const downloaded = new Set([...entryClosure, ...routeClosure[previous]]);
  const missing = [...routeClosure[next]].filter((m) => !downloaded.has(m));
  console.log(`${previous} -> ${next}`.padEnd(52),
    String(missing.reduce((t, m) => t + MODULE[m].size, 0)).padStart(4), "kB ",
    missing.sort().join(", ") || "-");
}
```

```
-- chunks --
entry     62 kB  core, design-system
chunk-1   18 kB  table
            requested by: /station  /station/:id  /station/:id/measurements
chunk-2   15 kB  form-validation
            requested by: /session  /station/:id/settings
chunk-3   12 kB  date-formatting
            requested by: /station/:id  /station/:id/measurements
chunk-4  126 kB  chart, export
            requested by: /station/:id/measurements
chunk-5  120 kB  map
            requested by: /station
-- initial load cost --
route                      single bundle split gain
/session                         353        77    78%
/station                         353       200    43%
/station/:id                     353        92    74%
/station/:id/measurements        353       218    38%
/station/:id/settings            353        77    78%
-- extra download per navigation --
/station -> /station/:id                               12 kB  date-formatting
/station/:id -> /station/:id/measurements             126 kB  chart, export
/station/:id/measurements -> /station/:id/settings     15 kB  form-validation
/session -> /station                                  138 kB  map, table
```

The chunk list is the result of signature grouping. The design system falls into the
entry chunk because every route uses it. The table module is shared by three routes and
sits in a single chunk; it is not copied into three routes, nor is it loaded into the
sign-in screen that does not want it. The export and chart modules merge into the same
chunk, because only the measurement history requests either one — splitting them into
separate chunks would produce an extra request with no gain for any route.

#### The Measured Gain

The second table states why splitting is worth doing, in numbers. A user arriving at
the sign-in screen downloads 77 kB instead of 353 kB. The gain varies by route: it is
smaller on routes that request the map or the chart, because the real weight sits
there.

Two decisions follow from this. Splitting a heavy module pays off far more than
splitting a light one; splitting effort is directed by size. And the gain is bounded by
the size of the entry: the 62 kB downloaded on every visit is not reduced by any amount
of splitting. Putting an unnecessary module into the entry chunk gives back what
splitting gained.

Splitting also has a cost that does not show up in the table: every chunk is a separate
request. This cost, defined in the Bundler Concept lesson of the Modules, Tooling and
the Ecosystem course, can outweigh the bytes downloaded for very small chunks. The rule
is this: a chunk is not split out if it does not pay for itself in avoided download.

#### The Cost Per Navigation

The third table shows splitting's effect after the initial load. Moving from the
station detail view to the measurement history requires a new 126 kB download. This
download starts the moment the user presses the link; until it finishes, there is no
content on screen.

With a single bundle, this wait did not exist — the code had already downloaded.
Splitting redistributes the initial load time into navigation time. This trade-off
is not always worth it: delaying the code for a screen the great majority of users enter
gives back, during navigation, more than what was gained on initial load.

The mechanism that removes this trade-off is **prefetching**. A chunk is downloaded
ahead of need, during idle time. Routing has two good signals for this: links that
become visible on screen, and the link the user hovers over. The intersection observer
introduced in The Browser and the Web Platform course supplies the first; pointer
events supply the second.

Prefetching must never get ahead of the real request. It is done at low priority and
triggered in an idle window; on metered network connections it is not done at all.
Otherwise it competes with the data the user actually needs at that moment.

#### Waiting and Error States

Loading a split chunk is an asynchronous operation; two new states follow from this,
and both have to be met in the interface.

The **waiting** state determines what shows while the chunk downloads. Leaving a blank
screen leads the user to think navigation is not working. The correct behavior is to
keep the preserved part of the layout chain on screen and place an indicator only in
the changing outlet: because the shell stays in place, the screen does not jar. For very
short waits, the indicator flashing on and off is also uncomfortable; it is shown only
after a small delay.

The **error** state is more often forgotten. The network may have dropped; the chunk
may have been removed from the server. The second case is more common than expected:
if a new version is published while the user has the page open, the chunk names the
old page requests no longer exist. This is a direct application of the Error and Retry
Patterns lesson from the Asynchronous JavaScript and the Runtime course: a failed load
is retried once, and if it fails again, an error view is shown suggesting the user
refresh the page.

A third trap intersects with guards. The chunk for a screen an unauthorized user could
never see should not be downloaded at all; the guard decision is made **before** the
chunk request. Otherwise, splitting both downloads unnecessary bytes and tells an
unauthorized user that the screen exists.

#### Summary

- A route's cost is the closure of every module reachable from it in the dependency
  graph.
- Modules are grouped by usage signature — the set of routes that request them;
  modules sharing a signature go into the same chunk, so nothing is duplicated or
  downloaded unnecessarily.
- Splitting's gain is bounded by the size of the entry and concentrated in heavy
  modules; because every chunk carries a request cost, a split that does not pay off
  is not made.
- Splitting redistributes initial load time into navigation time; prefetching done
  during idle time at low priority softens this trade-off.
- Chunk loading is asynchronous: the waiting state keeps the preserved shell on screen,
  and the error state needs a retry and a clear error view.
- The guard decision is made before the chunk request; the code for a screen the user
  cannot see is never downloaded.

#### Next Step

Routing is complete: the address determines which screen appears, who it is open to,
and which code gets downloaded. A kind of state carried by the address was also
defined — filter, sort, page. The rest of the application's state, though, is still
scattered: identity information sits in the guards, the station list somewhere else,
open panels inside components, measurements from the server somewhere else again.
Calling all of this "state" hides the most important difference among them: some of it
is produced by the application itself, some of it is a copy of a remote source. The
next topic starts by splitting state into its kinds and establishes the criteria that
decide where each kind belongs.

## State Management

Placing state according to its kind — the centralized store and reducers, atom-based fine-grained reactivity, the server state cache, optimistic updates, and persistence.

### State Types

Source: https://academia.sh/en/courses/frontend-architecture/state-types

Classifying state by kind — local, shared, URL, and server state, along with derived values; the decision procedure that places each kind, the single source of truth principle, and the cost of misplacement.

Routing is complete: the address decides which screen appears, who it is open to, and
which code downloads. One kind of state the address carries has also been defined —
filter, sort order, page. The rest of the North Slope Measurement Station application's
state is scattered: session information sits in guards, the station list sits somewhere
else, open panels sit inside components, and measurements from the server sit in yet
another place.

Calling all of this "state" hides the most important difference among them. Some are
values the application itself produces and solely owns; some are copies of a remote
source. This lesson separates state into kinds, gives the criterion that decides where
each kind belongs, and shows the cost of misplacing it.

#### Five Kinds

**Local state** is a value a single component reads and writes: whether a dropdown is
open, a text box's current content. It is born with the component and dies with it.

**Shared state** is a value multiple components read, produced by the application
itself: the interface theme, whether the side navigation is open, the selected unit
system. Its owner is the application; nothing else changes it.

**URL state** is state carried in the address. The previous topic defined it: state
expected to return when shared, bookmarked, and returned to with the back button.

**Server state** is the client-side copy of a remote source: the station list,
measurement records, the signed-in user's information. What sets it apart from the
other four is that ownership does not sit with the client.

**Derived value**, however, is not actually state. Anything computable from other state
falls into this class: the length of a filtered list, the total of selected items, a
flag showing whether a request is still in flight.

This set of five can be turned into a decision procedure.

```js
// state-types.mjs — decision procedure that places state and the cost of storing a derived value
function classify(item) {
  if (item.derivedFrom) return ["derived", `computed from ${item.derivedFrom}`];
  if (item.source === "server") return ["server", "owner is remote, a copy is kept"];
  if (item.inUrl) return ["URL", "must return when shared"];
  if (item.consumers > 1) return ["shared", `${item.consumers} separate consumers read it`];
  return ["local", "a single component reads it"];
}

const ITEMS = [
  { name: "station list", source: "server", consumers: 2 },
  { name: "measurement history page no", inUrl: true, consumers: 2 },
  { name: "selected measurement type filter", inUrl: true, consumers: 3 },
  { name: "signed-in user", source: "server", consumers: 6 },
  { name: "side nav open", consumers: 2 },
  { name: "interface theme", consumers: 5 },
  { name: "form field current value", consumers: 1 },
  { name: "dragged slider live value", consumers: 1 },
  { name: "filtered measurement count", derivedFrom: "measurements + filter" },
  { name: "loading indicator", derivedFrom: "server state" },
];

console.log("-- placement --");
for (const item of ITEMS) {
  const [kind, reason] = classify(item);
  console.log(item.name.padEnd(38), kind.padEnd(12), reason);
}

// Storing a derived value versus computing it on every read.
const filterBy = (measurements, type) => measurements.filter((m) => type === "all" || m.type === type);

let state = {
  measurements: [{ type: "temperature", v: -4.2 }, { type: "humidity", v: 71 }, { type: "temperature", v: -3.8 }],
  filter: "temperature",
  storedCount: 2,                       // derived value updated manually
};
const printLine = (label) => console.log(
  label.padEnd(34),
  "stored:", String(state.storedCount).padEnd(3),
  "computed:", filterBy(state.measurements, state.filter).length);

console.log("-- derived value --");
printLine("start");

state = { ...state, measurements: [...state.measurements, { type: "temperature", v: -5.1 }] };
printLine("new temperature reading added");

state = { ...state, filter: "humidity" };
printLine("filter switched to humidity");

state = { ...state, storedCount: filterBy(state.measurements, state.filter).length };
printLine("stored count corrected manually");

state = { ...state, measurements: state.measurements.filter((m) => m.type !== "humidity") };
printLine("humidity readings removed");
```

```
-- placement --
station list                           server       owner is remote, a copy is kept
measurement history page no            URL          must return when shared
selected measurement type filter       URL          must return when shared
signed-in user                         server       owner is remote, a copy is kept
side nav open                          shared       2 separate consumers read it
interface theme                        shared       5 separate consumers read it
form field current value               local        a single component reads it
dragged slider live value              local        a single component reads it
filtered measurement count             derived      computed from measurements + filter
loading indicator                      derived      computed from server state
-- derived value --
start                              stored: 2   computed: 2
new temperature reading added      stored: 2   computed: 3
filter switched to humidity        stored: 2   computed: 1
stored count corrected manually    stored: 1   computed: 1
humidity readings removed          stored: 1   computed: 0
```

The procedure's ordering is not arbitrary. Derivability is tested first, because a
derived value is not placed anywhere. Server origin comes second: the ownership
question precedes the question of how many components read it. The address test comes
before the sharing test, because state written to the address already becomes readable
application-wide.

#### The Cost of Storing a Derived Value

The second part of the output walks through a single defect step by step. The filtered
measurement count is stored in a state field and updated manually.

When a new measurement is added, the stored value stays at two while the computed value
rises to three. When the filter switches to humidity, the gap between them reverses
direction. When the stored value is corrected manually, the two agree for a moment — but
the next change pulls them apart again. Every new write site is a new place where the
update can be forgotten.

This is an instance of a single rule: **the same information is not kept in two
places.** When a derived value is stored, two sources of truth appear and which one is
correct becomes ambiguous. If the cost of computation is a concern, the answer is not
storing it but memoizing the computation; that is the subject of the Derived Values
lesson in the Component-Based Interface Development course, and the result is still a
single source of truth.

Treating the loading indicator as derived rests on the same reasoning. When a separate
flag is kept, the case where the request finishes and the flag is not turned off is
unavoidable — on the error path, on cancellation, on a concurrent second request. The
indicator's true source is the request's own state.

#### The Separateness of Server State

The other four are values the application itself produces: the written value is
correct, because the application is the one writing it. This does not hold for server
state. The station list on the client is a copy of the remote list at some past moment.
Another user may have added a new station, someone may have corrected a measurement.

This difference raises four questions, none of which is asked for the other kinds of
state. How long is the copy considered current? When it goes stale, when is it
refreshed? If two components request the same data, how many requests are made? While a
local change is being written to the server, what appears on screen?

Placing server state in the same location as shared state loses these four questions.
The application takes the copy for the truth: data is fetched once, kept in memory, and
never refreshed. The user cannot see, in the list, a station they added in another tab.
For this reason server state is kept in a separate layer, with its own freshness rules;
that layer is the subject of this topic's fourth lesson.

#### The Placement Principle

The principle that decides where state belongs is keeping it as low as possible: a
value only one component cares about is not lifted up.

The reasoning is a direct consequence of the one-way flow from the Component-Based
Interface Development course. Every value lifted up creates a dependency the components
in between are forced to see; it also announces its change to components it does not
affect. State kept low concerns only its own component.

There is a trap in the opposite direction: keeping state lower than it needs to be.
When two sibling components each keep the same value inside themselves, two copies form
and drift apart. The criterion is fixed: the **nearest common ancestor** of the
components reading the value. It cannot be kept lower than that, and keeping it higher
is unnecessary.

This principle does not apply to two kinds. URL state's location is already fixed;
server state, meanwhile, does not belong to any node of the component tree, because
components in different parts of the tree request the same data and the copy must be
singular.

#### Summary

- State separates into five classes: local, shared, URL, server, and derived; the first
  four are stored, the fifth is computed.
- The decision procedure asks derivability first, then ownership, then the address, and
  consumer count last.
- When a derived value is stored, two sources of truth form and every write site risks
  drifting apart again; the answer is not storing it but memoizing the computation when
  needed.
- Server state's owner is not the client; the questions of freshness, refresh, request
  repetition, and what appears on screen during a write are asked only for this kind.
- Client state is kept at the nearest common ancestor of the components that read it —
  keeping it lower produces copies, keeping it higher creates an unnecessary dependency.

#### Next Step

The kinds are separated, but how shared state is held is not yet clear. A value kept at
the common ancestor gets threaded through dozens of components that do not use it as the
tree deepens; worse, who changes this value and in which event scatters through the
code. Selecting a row in the station list, deleting a measurement, signing out — all
three change the same state from different places, and when a bug appears, who made the
change cannot be traced. The next lesson covers the centralized store pattern, which
collects state in a single place, accepts change only through named actions, and defines
transitions with a pure function.

### Centralized Store Pattern

Source: https://academia.sh/en/courses/frontend-architecture/centralized-store-pattern

The pattern that collects state in one place — one-way data flow, the pure reducer, the action contract, filtering unnecessary notifications with selectors, where middleware fits, and the pattern's indirection cost.

State kinds are separated, but how shared state is held is not clear. A value kept at
the common ancestor gets threaded through dozens of components that do not use it as the
tree deepens. The heavier problem is the scatter of change: in the North Slope
Measurement Station application, selecting a row, deleting a measurement, and signing
out change the same state from three separate places. When a wrong value appears on
screen, who wrote it cannot be traced.

The **centralized store** pattern answers both problems with a single constraint: state
is held in one place and changes only through named actions.

#### One-Way Flow

The pattern's backbone is a cycle that closes across four stops. The view **dispatches**
an **action**; the action goes to the **reducer**; the reducer produces the new state;
the new state flows back to the view.

There is no way to change state outside this cycle. The constraint is the source of all
the pattern's gains: because every change passes through a single point, changes can be
ordered, recorded, and replayed.

The three parts are defined separately. An **action** is a data object reporting an
event that has occurred; it has a type and as many fields as needed. A **reducer** is a
pure function that takes the current state and an action and returns the new state. A
**store** is the container that holds the current state and provides dispatching
actions and observing change.

#### The Reducer

The reducer is a pure function: the same input always produces the same output, and
nothing outside changes. It makes no network request, generates no random number, reads
no current time.

It does not **mutate** state, it produces a new one. This is a direct application of
the immutability rule from the Programming Fundamentals course, and it has a practical
consequence: if a slice's reference has not changed, that slice has not changed. This
cheap test decides when the view is redrawn.

```js
// centralized-store.mjs — reducer, action sequence, middleware, selector, and replay
const INITIAL_STATE = { stations: [], selected: null, filter: "all" };

function reducer(state, action) {
  switch (action.type) {
    case "stations/loaded":
      return { ...state, stations: action.data };
    case "station/selected":
      return state.stations.some((s) => s.id === action.id)
        ? { ...state, selected: action.id }
        : state;                                   // invalid selection does not change state
    case "station/added":
      return { ...state, stations: [...state.stations, action.station] };
    case "station/deleted":
      return {
        ...state,
        stations: state.stations.filter((s) => s.id !== action.id),
        selected: state.selected === action.id ? null : state.selected,
      };
    case "filter/changed":
      return { ...state, filter: action.value };
    default:
      return state;                                // unknown action: same reference
  }
}

function createStore(reducer, initial, middlewares = []) {
  let state = initial;
  const listeners = new Set();
  const base = (action) => {
    state = reducer(state, action);
    for (const l of listeners) l(state);
  };
  const dispatch = middlewares.reduceRight(
    (next, mw) => mw({ getState: () => state })(next), base);
  return { getState: () => state, dispatch, subscribe: (f) => listeners.add(f) };
}

const log = [];
const logMiddleware = ({ getState }) => (next) => (action) => {
  const previous = getState();
  next(action);
  log.push({ action: action.type, changed: getState() !== previous, state: getState() });
};

const ACTIONS = [
  { type: "stations/loaded", data: [{ id: "north-slope" }, { id: "east-ridge" }] },
  { type: "station/selected", id: "north-slope" },
  { type: "filter/changed", value: "temperature" },
  { type: "station/selected", id: "west-valley" },     // not in list
  { type: "station/added", station: { id: "west-valley" } },
  { type: "station/selected", id: "west-valley" },
  { type: "station/deleted", id: "west-valley" },
  { type: "theme/changed", value: "dark" },               // reducer does not define this
];

const store = createStore(reducer, INITIAL_STATE, [logMiddleware]);

// Selector: the slice the component cares about. If the reference did not change, no re-render is needed.
const selectorStats = { notified: 0, listChanged: 0 };
let previousList = store.getState().stations;
store.subscribe((s) => {
  selectorStats.notified += 1;
  if (s.stations !== previousList) { selectorStats.listChanged += 1; previousList = s.stations; }
});

for (const a of ACTIONS) store.dispatch(a);

console.log("-- action log --");
console.log("#".padEnd(3), "action".padEnd(24), "changed".padEnd(8), "stations", "selected");
log.forEach((g, i) => console.log(
  String(i + 1).padEnd(3), g.action.padEnd(24), String(g.changed).padEnd(8),
  String(g.state.stations.length).padEnd(8), String(g.state.selected)));

console.log("-- listener --");
console.log("dispatched actions:", ACTIONS.length,
  "| notifications:", selectorStats.notified,
  "| list slice changed:", selectorStats.listChanged);

// The reducer is pure, so the same sequence gives the same result.
console.log("-- replay --");
const replayed = ACTIONS.reduce(reducer, INITIAL_STATE);
console.log("final state matches:", JSON.stringify(replayed) === JSON.stringify(store.getState()));
console.log("after action 3:", JSON.stringify(ACTIONS.slice(0, 3).reduce(reducer, INITIAL_STATE)));
console.log("final state    :", JSON.stringify(store.getState()));
```

```
-- action log --
#   action                   changed  stations selected
1   stations/loaded          true     2        null
2   station/selected         true     2        north-slope
3   filter/changed           true     2        north-slope
4   station/selected         false    2        north-slope
5   station/added            true     3        north-slope
6   station/selected         true     3        west-valley
7   station/deleted          true     2        null
8   theme/changed            false    2        null
-- listener --
dispatched actions: 8 | notifications: 8 | list slice changed: 3
-- replay --
final state matches: true
after action 3: {"stations":[{"id":"north-slope"},{"id":"east-ridge"}],"selected":"north-slope","filter":"temperature"}
final state    : {"stations":[{"id":"north-slope"},{"id":"east-ridge"}],"selected":null,"filter":"temperature"}
```

The log's fourth and eighth rows show the same behavior for two different reasons.
Selecting a station not in the list is rejected; the reducer returns the current state
with the same reference. An unrecognized action type is handled the same way. In both
cases no view needs to redraw, and a single reference comparison is enough to know that.

The seventh row shows that a single action can affect more than one slice. When the
selected station is deleted, the list shortens and the selection clears at once; these
two changes are not split into separate actions, because no moment must exist between
them where the screen is left inconsistent. The invariant is this: the selected id is
either empty or found in the list.

#### Replay

The last section shows what purity buys. When the action sequence is reduced from the
start, the exact state that formed in the store is obtained. A store is not even
needed; state is a function of the initial value and the action sequence.

This has three uses. When a bug report from a user comes with the action log, the
problem can be reproduced locally. Returning to any moment is done by re-reducing the
actions up to that point — the output's state after the third action was produced this
way. And an undo function is built by removing the deleted action and re-reducing the
sequence.

These capabilities depend on the reducer's purity. The moment a single line reading the
current time is added inside it, replay produces a different result and all three are
lost.

#### Selector

The listener section gives, in numbers, the pattern's most commonly overlooked cost:
eight actions were dispatched, eight notifications were produced, but the station list
changed only three times.

The store does not know which part of state changed; it notifies all listeners on
every dispatch. Components must filter these notifications themselves. A **selector**
is a function that extracts, from state, the slice a component cares about; if the
slice's reference has not changed, no redraw happens.

The selector has one trap. A selector that produces a new object or array on every call
— one that filters, maps, or aggregates into an object — always fails the reference
comparison, and the filter never works. Selectors of this kind are memoized so they
return the same result as long as their inputs have not changed.

#### Middleware

If the reducer must be pure, where do a network request, writing to a log, and saving
to storage go? **Middleware** is a wrapper that sits on the dispatch path and processes
an action before or after it reaches the reducer.

The log middleware in the example passes the action through, then compares the state
before and after and records it. Other responsibilities sit in the same place: turning
an action into an asynchronous process, writing state to storage after certain actions,
validating an incoming action.

The order of middleware is meaningful: the outer layer wraps the inner one. If the log
middleware sits outermost, it also sees the actions an asynchronous layer produces; if
it sits innermost, it sees only the ones that reach the reducer.

#### The Pattern's Cost

The centralized store is not free. To change a single field, an action type is
defined, a branch is added to the reducer, a dispatch is written — three files are
touched. This indirection is paid for a single thing: the traceability of change. Where
traceability has no value, the cost is also unnecessary.

Two limits follow from this. Local state is not put in the store; defining an action
for a dropdown's openness is indirection that brings no gain. Server state is not put
in it either — it can be copied, but the questions of freshness, refetching, and
request repetition are questions the reducer cannot solve; that layer is the subject of
this topic's fourth lesson.

What belongs in the store is shared state the application itself produces and that is
changed from more than one place.

#### Summary

- The centralized store pattern collects state in one place and accepts change only
  through named actions; the flow closes in one direction across view, action, reducer,
  and state.
- The reducer is a pure function that produces new state without mutating it; an
  unchanged slice keeps its reference, which reduces the redraw decision to a single
  comparison.
- Purity makes it possible to reproduce state from the action sequence: a bug can be
  replayed, a past moment can be returned to, undo can be built.
- The store notifies all listeners on every dispatch; selectors filter out unnecessary
  redraws, and a selector that produces a new object does not filter anything unless it
  is memoized.
- Side effects live in middleware; the order of middleware decides which actions get
  seen.
- The pattern's cost is indirection; local state and server state are not put in the
  store.

#### Next Step

The centralized store established a single source of truth, but it came with a cost:
every change renews a single state object and is announced to every listener.
Unnecessary redrawing is prevented by correctly written selectors — meaning correctness
depends on every consumer building its own filter flawlessly. There is another approach
that reverses this responsibility: defining state not as one large object but as small
units linked to each other. Then who reads what falls out of the dependency itself, and
only the affected consumers are notified. The next lesson covers this fine-grained
model — atoms and derived atoms.

### Atom-Based Approaches

Source: https://academia.sh/en/courses/frontend-architecture/atom-based-approaches

Fine-grained reactive state — atom and derived atom, the dependency graph, invalidation propagation and lazy computation, comparing recompute and notification counts against the centralized store, and the graph model's cost.

The centralized store established a single source of truth, but it came with a cost:
every change renews a single state object and is announced to every listener.
Selectors prevent unnecessary redraws — meaning correctness depends on every consumer
building its own filter flawlessly. When a selector is forgotten or produces a new
object on every call, the defect is silent: the screen looks correct, it just redraws
more than it should.

There is another model that reverses this responsibility. State is defined not as one
large object but as small units linked to each other. Then who reads what falls out of
the dependency itself, and the filtering work moves from the consumer to the graph.

#### Atom and Derived Atom

An **atom** is the smallest unit of state that can be independently read and written.
In the North Slope Measurement Station application, the measurement list is an atom,
the filter criterion is an atom, the sort direction is an atom, the interface theme is
an atom. There is no enclosing object between them; each stands on its own.

A **derived atom**, in contrast, cannot be written; it is computed from its inputs. The
filtered list, the sorted list, and the summary are each derived atoms. The rule from
the State Types lesson becomes concrete here as a unit: a derived value is not stored,
it is defined.

Together the two form a **dependency graph**. Edges are drawn in the read direction:
the filtered list reads the measurement list and the filter. Views also behave like the
graph's leaves; they attach to the nodes they read.

The graph's operation has two steps. When an atom is written, every derived node
reachable from it is **invalidated** — its value is no longer considered valid. An
invalidated node's value is recomputed when it is **read**. No computation happens at
write time.

```js
// atom-graph.mjs — atoms, derived atoms, invalidation propagation, and recompute counting
const recomputed = [];

function atom(name, value) {
  return { name, kind: "atom", value, dependents: new Set() };
}

function derived(name, inputs, compute) {
  const d = { name, kind: "derived", inputs, compute, dirty: true, value: undefined, dependents: new Set() };
  for (const i of inputs) i.dependents.add(d);
  return d;
}

function consumer(name, inputs) {
  const c = { name, kind: "consumer", inputs, dependents: new Set() };
  for (const i of inputs) i.dependents.add(c);
  return c;
}

function read(d) {
  if (d.kind === "atom") return d.value;
  if (d.dirty) {
    d.value = d.compute(...d.inputs.map(read));
    d.dirty = false;
    recomputed.push(d.name);
  }
  return d.value;
}

function write(a, value, active) {
  recomputed.length = 0;
  a.value = value;
  const notified = new Set();
  const invalidate = (d) => {
    for (const dep of d.dependents) {
      if (dep.kind === "consumer") { notified.add(dep.name); continue; }
      if (dep.dirty) continue;                      // already dirty, propagation stops
      dep.dirty = true;
      invalidate(dep);
    }
  };
  invalidate(a);
  const rendered = active.filter((c) => notified.has(c.name));   // consumers off-screen are not notified
  for (const c of rendered) c.inputs.forEach(read);
  return { notified: rendered.map((c) => c.name), recomputed: [...recomputed] };
}

const measurements = atom("measurements", [
  { type: "temperature", v: -4.2 }, { type: "humidity", v: 71 }, { type: "temperature", v: -3.8 }]);
const filter = atom("filter", "all");
const sortDir = atom("sortDir", "descending");
const theme = atom("theme", "light");

const filtered = derived("filtered", [measurements, filter],
  (m, f) => f === "all" ? m : m.filter((x) => x.type === f));
const sorted = derived("sorted", [filtered, sortDir],
  (m, s) => [...m].sort((a, b) => s === "ascending" ? a.v - b.v : b.v - a.v));
const summary = derived("summary", [filtered],
  (m) => ({ count: m.length, average: m.length ? m.reduce((t, x) => t + x.v, 0) / m.length : null }));
const header = derived("header", [theme], (t) => ({ background: t === "dark" ? "#111" : "#fff" }));

const List = consumer("List", [sorted]);
const SummaryCard = consumer("SummaryCard", [summary]);
const Header = consumer("Header", [header]);

let active = [List, SummaryCard, Header];
active.forEach((c) => c.inputs.forEach(read));          // first render

console.log("-- notified consumer and recompute per write --");
const steps = [
  ["theme = dark", () => write(theme, "dark", active)],
  ["sortDir = ascending", () => write(sortDir, "ascending", active)],
  ["filter = temperature", () => write(filter, "temperature", active)],
  ["measurements += record", () => write(measurements, [...measurements.value, { type: "temperature", v: -5.1 }], active)],
];
let totalNotified = 0, totalRecomputed = 0;
for (const [label, run] of steps) {
  const { notified, recomputed: recomp } = run();
  totalNotified += notified.length; totalRecomputed += recomp.length;
  console.log(label.padEnd(25), "notified:", (notified.join(", ") || "-").padEnd(26),
    "recomputed:", recomp.join(", ") || "-");
}

console.log("-- SummaryCard removed from screen --");
active = [List, Header];
const s = write(filter, "humidity", active);
totalNotified += s.notified.length; totalRecomputed += s.recomputed.length;
console.log("filter = humidity".padEnd(25), "notified:", (s.notified.join(", ") || "-").padEnd(26),
  "recomputed:", s.recomputed.join(", ") || "-");
console.log("summary dirty:", summary.dirty);

console.log("-- total --");
console.log("fine-grained : notified", totalNotified, "| recomputed", totalRecomputed);
console.log("single object: notified", 5 * 3, "| (every consumer on every write)");
```

```
-- notified consumer and recompute per write --
theme = dark              notified: Header                     recomputed: header
sortDir = ascending       notified: List                       recomputed: sorted
filter = temperature      notified: List, SummaryCard          recomputed: filtered, sorted, summary
measurements += record    notified: List, SummaryCard          recomputed: filtered, sorted, summary
-- SummaryCard removed from screen --
filter = humidity         notified: List                       recomputed: filtered, sorted
summary dirty: true
-- total --
fine-grained : notified 7 | recomputed 10
single object: notified 15 | (every consumer on every write)
```

Each row shows the effect of a single write. When the theme changes, only the header
node is recomputed; the measurement list is untouched. When the sort direction changes,
the filtered list stays in place and only the sort is redone — the filtering work is
not repeated. When the filter changes, three nodes are invalidated at once, because two
of them depend on the filtered list.

One detail of the propagation is written into the code: propagation stops at a node
that is already dirty. Even if a node is reached by two paths in the same write, it is
marked once and computed once.

#### Lazy Computation

The write made after the summary card is removed from the screen shows the model's
second gain. The filtered list and sorted list are recomputed because the list is still
on screen. The summary node, however, is only **invalidated**, not computed; the
output's last line confirms this.

The reason is that computation happens at read time, not at write time. A derived value
nobody reads is never computed. When the summary card returns to the screen, the read
occurs and the value is produced at that moment.

This behavior is the reactive counterpart of lazy evaluation from the Programming
Fundamentals course, and it can be decisive on its own for expensive derivations: a
computation that groups a thousand-row table never runs as long as that table is not
shown.

#### Comparison with Coarse-Grained

The last two lines compare the two models in the same scenario. Across five writes, the
fine-grained graph produced seven consumer notifications; the single-object store,
without selectors, would have produced fifteen.

The difference is one selectors can close — but it is a difference that must be closed.
In the atom model, the filter falls out of the dependency itself: a consumer depends on
whatever it reads. In the centralized store, the filter is written by hand, and nothing
warns when it is written wrong.

There is also something lost in return. In the centralized store, every change passes
through a single action stream; it can be ordered, recorded, replayed. In the atom
graph, writes go directly to the atom; the question "who changed this value" has no
single point of answer. Action logging and replay do not come for free in this model.

#### The Graph's Cost

Fine-grained granularity brings three new problems.

**Scatter.** The state definition is not collected in one place; atoms spread out next
to where they are used. In a small application this is a gain. As it grows, finding out
who writes a given atom turns into scanning files. The countermeasure is to put write
operations behind named functions and not leave the atom directly writable.

**Dependency cycle.** A derived atom indirectly reading itself sends invalidation
propagation into an infinite loop. The example's invalidation propagation is partly
protected against this — it stops at an already-dirty node — but the read side falls
into infinite recursion. The graph must be checked for acyclicity as it is built; the
directed acyclic graph concept from the Data Structures course is a constraint here.

**Identity.** When an atom is needed per list item — such as each measurement row's own
selected state — atoms form a **family** and are produced by key. Cleaning up this
family is done by hand; if the atom for an item removed from the screen is not
released, a memory leak forms. The memory leak diagnosis from the Asynchronous
JavaScript and the Runtime course applies here directly.

#### Which Model Where

The two models are not alternatives to each other; they are tools chosen according to
different criteria.

The centralized store is preferred when the traceability of change is the first
priority: complex business rules, an undo requirement, multi-step flows, an action log
delivered together with a bug report.

The atom model is preferred when granularity is the first priority: a large number of
mutually independent small pieces of state, expensive derivations, values that update
often and have few consumers.

The two can also coexist in the same application. The criterion is not the pattern
itself but which question that piece of state raises.

#### Summary

- An atom is the smallest unit of state that can be read and written independently; a
  derived atom cannot be written, it is computed from its inputs and not stored.
- A write invalidates the nodes reachable in the graph; computation happens at read
  time, not write time, so a derived value nobody reads is never computed.
- Because invalidation propagation stops at an already-dirty node, every node is
  computed at most once per write.
- In the fine-grained graph, the filtering work falls out of the dependency itself; in
  the centralized store, the same result is obtained with hand-written selectors and is
  silently lost when written wrong.
- Because there is no single action stream in the atom model, action logging, replay,
  and undo do not come for free.
- Model choice depends on the criterion: traceability favors the centralized store,
  granularity favors the atom graph; the two can also be used together.

#### Next Step

Both models share one assumption: the application owns the state, and the written
value is correct. The North Slope Measurement Station application's largest mass of
state does not meet this assumption. The station list and measurement records are
copies of a remote source; another user may have added a new station, someone may have
corrected a measurement. This state's question is not "where is it held" but "how long
is it considered current." The next lesson covers server state in its own layer: the
cache key, the freshness window, reducing two components requesting the same data to a
single request, invalidation, and refetching.

### Server State Management

Source: https://academia.sh/en/courses/frontend-architecture/server-state-management

The client-side copy of remote data — the cache key, the freshness window, in-flight deduplication, serving stale while revalidating in the background, invalidation after a write, the cache time, and refetch triggers.

The centralized store and the atom graph share the same assumption: the application
owns the state, the written value is correct. The North Slope Measurement Station
application's largest mass of state does not meet this assumption. The station list and
measurement records are a copy of a remote source; another user may have added a new
station, someone may have corrected a measurement.

The State Types lesson said this kind raises four separate questions: how long is the
copy considered current, when it goes stale when is it refreshed, if two components
request the same data how many requests are made, what appears on screen while a local
change is being written. The first three are the subject of this lesson, the fourth of
the next.

#### The Key

The server state layer's first decision is what a copy is identified by. The **cache
key** is produced from the values that fully determine the request: the resource's path
and every parameter that differentiates the request.

A key that is missing something conflates two copies: a key that does not account for
the filter keeps temperature measurements and humidity measurements in the same entry.
A key that is broader than it needs to be prevents sharing; if a value that does not
affect the request enters the key, every view downloads its own copy.

The canonical form from the Route Parameters and Query lesson finds its counterpart
here. Because the same view is identified by a single address, the key derived from the
address is also singular; a change in parameter order does not create a second cache
entry.

#### Freshness and Cache Time

The second decision is how long the copy is used without querying again. Two separate
durations are needed, and confusing them is a commonly seen defect.

The **freshness window** is the duration the copy can be used without being requested
again. A read inside the window never reaches the network. The **cache time** is the
duration the copy is kept in memory; once this duration elapses, the entry is dropped.

In the interval between the two, the copy is **stale**: it is shown to the user
immediately, and refreshed in the background at the same time. The screen is never left
empty, and the data never stays out of date.

```js
// server-state.mjs — freshness window, in-flight deduplication, invalidation, cache time
const FRESHNESS = 30;      // seconds: the copy is considered fresh within this duration
const CACHE_TIME = 120;    // seconds: the entry is dropped after this duration

const cache = new Map();      // key -> { data, receivedAt, invalid }
const inFlight = new Map();   // key -> request number
let requestNo = 0;

function request(key, clock) {                 // clock: virtual time supplied from outside
  const entry = cache.get(key);
  const age = entry ? clock - entry.receivedAt : null;
  if (entry && age > CACHE_TIME) { cache.delete(key); }
  const validEntry = cache.get(key);

  if (validEntry && !validEntry.invalid && clock - validEntry.receivedAt <= FRESHNESS)
    return { result: "cache hit", age: clock - validEntry.receivedAt, request: "-" };

  if (inFlight.has(key))
    return { result: "deduplicated in flight", age, request: `#${inFlight.get(key)}` };

  inFlight.set(key, ++requestNo);
  const result = validEntry
    ? "stale served + revalidating"
    : (entry ? "entry dropped, pending" : "no data, pending");
  return { result, age, request: `#${requestNo}` };
}

function complete(key, clock, data) {
  cache.set(key, { data, receivedAt: clock, invalid: false });
  const n = inFlight.get(key);
  inFlight.delete(key);
  return { result: "response written", age: 0, request: `#${n}` };
}

function invalidate(prefix) {                // called after a write completes
  let count = 0;
  for (const [key, entry] of cache)
    if (key.startsWith(prefix)) { entry.invalid = true; count += 1; }
  return { result: `${count} entries invalidated`, age: null, request: "-" };
}

const LIST = ["north-slope", "east-ridge"];
const STEPS = [
  [0, "List opened", "station/list", (t, k) => request(k, t)],
  [1, "SummaryCard requested same data", "station/list", (t, k) => request(k, t)],
  [3, "response arrived", "station/list", (t, k) => complete(k, t, LIST)],
  [5, "returned to tab", "station/list", (t, k) => request(k, t)],
  [40, "returned to tab", "station/list", (t, k) => request(k, t)],
  [42, "response arrived", "station/list", (t, k) => complete(k, t, LIST)],
  [45, "station detail opened", "station/north-slope", (t, k) => request(k, t)],
  [47, "response arrived", "station/north-slope", (t, k) => complete(k, t, { name: "North Slope" })],
  [50, "new station saved", "station/ (prefix)", (t) => invalidate("station/", t)],
  [51, "returned to list", "station/list", (t, k) => request(k, t)],
  [53, "response arrived", "station/list", (t, k) => complete(k, t, [...LIST, "west-valley"])],
  [200, "returned to detail", "station/north-slope", (t, k) => request(k, t)],
];

console.log("t".padStart(4), "event".padEnd(34), "key".padEnd(22), "result".padEnd(28), "age", "request");
for (const [t, event, key, run] of STEPS) {
  const r = run(t, key);
  console.log(String(t).padStart(4), event.padEnd(34), key.padEnd(22),
    r.result.padEnd(28), String(r.age ?? "-").padStart(3), r.request);
}

console.log("-- total network requests --", requestNo);
```

```
   t event                              key                    result                       age request
   0 List opened                        station/list           no data, pending               - #1
   1 SummaryCard requested same data    station/list           deduplicated in flight         - #1
   3 response arrived                   station/list           response written               0 #1
   5 returned to tab                    station/list           cache hit                      2 -
  40 returned to tab                    station/list           stale served + revalidating   37 #2
  42 response arrived                   station/list           response written               0 #2
  45 station detail opened              station/north-slope    no data, pending               - #3
  47 response arrived                   station/north-slope    response written               0 #3
  50 new station saved                  station/ (prefix)      2 entries invalidated          - -
  51 returned to list                   station/list           stale served + revalidating    9 #4
  53 response arrived                   station/list           response written               0 #4
 200 returned to detail                 station/north-slope    entry dropped, pending       153 #5
-- total network requests -- 5
```

Because time is supplied from outside, the output is deterministic; the same script
produces the same lines on every run. In a real application the source of this value is
the system clock, but the layer itself stays testable if it takes the clock as a
dependency.

#### In-Flight Deduplication

The second row shows the layer's least visible gain. The list and the summary card
request the same data one second apart; only one request goes out to the network.

Without this behavior, every component on the same screen makes its own request. In the
waterfall view defined in the Network Panel Diagnostics lesson, this appears as
side-by-side requests going to the same address. The fix is not changing the components
— lifting the data up, distributing it from a single place — but merging requests that
go to the same key inside the layer. Components stay unaware of each other, and still
only one request is made.

For the merging to work, the key must be comparable by string equality. If key
generation is not canonical, two keys identifying the same request look different and
the merge is missed.

#### Invalidation

The step at the fiftieth second shows the situation the freshness window cannot solve.
The user has saved a new station; the list's copy is now wrong, but its age is still
only nine seconds. Duration cannot supply this information.

What can supply it is the write operation itself. When a write completes successfully,
the keys it affects are **invalidated**: they are considered stale regardless of their
age. In the output, invalidation was done by prefix and affected two entries at once.

Determining the affected keys is a design decision. A narrow invalidation — only the
key of the changed record — is fast but leaves the list stale. A broad invalidation —
the entire prefix — is safe but produces unnecessary requests. The criterion is which
views the write actually affects; a new station changes both the list and the
summaries.

Invalidation has a sibling: writing the response directly. If the server returns the
current record in response to the write request, that record is placed in the cache
and no additional request is ever made. This also concerns the server side of the
contract and is covered in the Data Access topic.

#### Cache Time

The last row occurs at the two-hundredth second. The station detail's copy has
exceeded the cache time and has been dropped; when the user returns, not even stale
data can be shown, and a wait state appears.

The reason cache time exists is memory. If every record the user opened once and never
returned to stays in memory, a tab left open for a long time keeps growing. This is one
of the causes producing the pattern seen in the memory leak diagnosis from the
Asynchronous JavaScript and the Runtime course.

The two durations are set together. The freshness window is chosen according to how
often the data changes: a station's rarely changing profile wants a long window, a
continuously streaming measurement list wants a short one. The cache time is chosen
according to the likelihood the user returns to that view.

#### Refetch Triggers

When a stale entry is refreshed does not depend only on reading. The layer also
listens for a few external signals.

Returning to the tab is the most effective of these: when the user stays in another
application for minutes and comes back, everything on screen has gone stale. The
network connection re-establishing is the second signal. Refreshing at fixed intervals,
meanwhile, is used only for data that genuinely changes, and is stopped while the tab
is not visible.

Every trigger produces a cost. The criterion is comparing the cost of the user seeing
stale data against the cost of an extra request; staleness is expensive in the
measurement list, not in the station profile.

#### Summary

- Server state is held in its own layer, and every copy is identified by a canonical
  key that fully determines the request.
- The freshness window is the duration the copy is used without querying again; the
  cache time is the duration it stays in memory. The copy in between is stale: it is
  shown and refreshed in the background.
- Concurrent requests to the same key are deduplicated in flight; only one request is
  made even while components stay unaware of each other.
- Duration-based freshness cannot know that a local write has invalidated the copy;
  invalidation after a write makes the affected keys stale regardless of their age.
- An entry whose cache time has elapsed is dropped, and not even stale data can be
  shown on return; this duration bounds memory.
- Refetching is triggered, besides reading, by returning to the tab, the connection
  re-establishing, and interval-based refresh; each trigger is chosen by weighing
  staleness cost against request cost.

#### Next Step

This layer solved reading. On the write side, what appears on screen still depends on
the speed of the network: the user deletes a measurement, the button switches to
waiting, and the row disappears only once the server responds. Yet in most cases the
request will succeed and the result is already known in advance. The next lesson covers
reflecting a change onto the screen without waiting for the response, reverting to the
prior state on failure, and how that reversion can collide with other changes that
arrive in between.

### Optimistic Updates

Source: https://academia.sh/en/courses/frontend-architecture/optimistic-updates

Reflecting a change onto the screen without waiting for the response — how snapshot-based rollback breaks under concurrent changes, the pending mutation layer, reconciliation with the server response, and where optimism does not apply.

The server state layer solved reading: the copy is identified by a key, used within a
freshness window, refreshed through invalidation. On the write side, however, what
appears on screen still depends on the speed of the network. The user deletes a
measurement in the North Slope Measurement Station application; the button switches to
waiting, and the row disappears only once the server responds.

Yet the result of the request is known in advance in most cases. If the delete will
succeed, it is already known that the row will disappear. An **optimistic update** is
reflecting the expected result onto the screen before the response, and reconciling
once the response arrives.

#### What It Buys

The gain is not a shorter network duration — the request still takes the same time. It
is in the user's perception of waiting. Even a hundred-millisecond delay accumulates
across operations done back to back: a user deleting ten measurements one by one waits
for the screen to freeze on every delete.

Optimism also simplifies the interface. Every button does not have to have a waiting
state, every row does not have to have a "deleting" appearance; the screen shows the
result directly.

In return, a promise is made: what appears on screen is unconfirmed. When the promise
is broken — the request fails — the screen must roll back. That rollback is this
lesson's subject, because the obvious method fails silently in one situation.

#### Two Methods of Rollback

The first method is direct: a **snapshot** of the screen is taken when the change
starts, and that snapshot is restored if the request fails.

The second method inserts a layer in between. Data from the server is kept separately
as the **base**; incomplete changes sit in a **pending mutation** list; what appears on
screen is recomputed every time by applying the pending changes on top of the base.
Failure means removing that change from the list.

The two give the same result when there is only one change. The difference appears
when two changes overlap.

```js
// optimistic-update.mjs — comparing snapshot rollback with the pending layer method
const BASE = ["T-01", "T-02", "T-03"];

const CHANGES = {
  A: { label: "delete T-02", apply: (l) => l.filter((x) => x !== "T-02") },
  B: { label: "add T-04", apply: (l) => [...l, "T-04"] },
};

// Method 1: a snapshot of the screen is taken when the change starts, restored on failure.
function snapshotMethod(events) {
  let screen = [...BASE];
  const snapshots = new Map();
  const rows = [];
  for (const [name, kind] of events) {
    if (kind === "start") { snapshots.set(name, [...screen]); screen = CHANGES[name].apply(screen); }
    else if (kind === "success") { snapshots.delete(name); }
    else { screen = snapshots.get(name); snapshots.delete(name); }
    rows.push([`${name} ${kind}`, [...screen]]);
  }
  return rows;
}

// Method 2: the server base is kept separate, pending changes are applied on top on every render.
function pendingLayerMethod(events) {
  let base = [...BASE];
  const pending = [];
  const render = () => pending.reduce((l, name) => CHANGES[name].apply(l), base);
  const rows = [];
  for (const [name, kind] of events) {
    if (kind === "start") pending.push(name);
    else {
      pending.splice(pending.indexOf(name), 1);
      if (kind === "success") base = CHANGES[name].apply(base);   // server confirmation is written into the base
    }
    rows.push([`${name} ${kind}`, render()]);
  }
  return rows;
}

function compare(title, events) {
  console.log(`-- ${title} --`);
  const a = snapshotMethod(events);
  const b = pendingLayerMethod(events);
  console.log("event".padEnd(14), "snapshot".padEnd(30), "pending layer");
  for (let i = 0; i < a.length; i++)
    console.log(a[i][0].padEnd(14), a[i][1].join(",").padEnd(30), b[i][1].join(","));
}

compare("single change, failure", [["A", "start"], ["A", "failure"]]);
compare("two changes, first fails", [
  ["A", "start"], ["B", "start"], ["B", "success"], ["A", "failure"]]);
compare("two changes, both succeed", [
  ["A", "start"], ["B", "start"], ["B", "success"], ["A", "success"]]);
```

```
-- single change, failure --
event          snapshot                       pending layer
A start        T-01,T-03                      T-01,T-03
A failure      T-01,T-02,T-03                 T-01,T-02,T-03
-- two changes, first fails --
event          snapshot                       pending layer
A start        T-01,T-03                      T-01,T-03
B start        T-01,T-03,T-04                 T-01,T-03,T-04
B success      T-01,T-03,T-04                 T-01,T-03,T-04
A failure      T-01,T-02,T-03                 T-01,T-02,T-03,T-04
-- two changes, both succeed --
event          snapshot                       pending layer
A start        T-01,T-03                      T-01,T-03
B start        T-01,T-03,T-04                 T-01,T-03,T-04
B success      T-01,T-03,T-04                 T-01,T-03,T-04
A success      T-01,T-03,T-04                 T-01,T-03,T-04
```

In the first scenario the two methods give the same result. The deleted row comes
back, and the screen returns to its pre-request state.

The second scenario's last row diverges. When the delete fails, the snapshot method
returns the screen to the state at the moment the delete started — and T-04, not yet
added then, disappears. The user sees the result of an operation they made no mistake
in vanish from the screen, with no explanation.

The pending layer method gives the correct result on the same row. The failed change
is removed from the list, and the base has already received the result of the
successful change; when the screen is recomputed, the deleted row comes back and the
added row stays in place.

The difference comes from the method itself: a snapshot stores the screen's result at
one moment, carrying the trace of other changes with it. The pending layer stores only
the change itself; rollback is removing a single change and recomputing.

#### Reconciliation with the Server Response

When a successful response arrives, the pending mutation is removed from the list, but
the base must also be updated so the screen does not break. There are two paths.

If the server response returns the current record, the base is updated directly and no
additional request is made. If it does not, the invalidation from the Server State
Management lesson applies: the affected keys are made stale and refreshed on the next
read. The second path opens a short gap — the pending change is removed but fresh data
has not arrived — and the screen can show old data for a moment. The pending change
can therefore be left in the list until the refresh completes.

There are also cases where the optimistic result can diverge from the server's. When a
new record is added, its id is produced by the server; the client's temporary id is
replaced with the real one once the response arrives. Timestamps, sequence numbers, and
server-computed totals belong to the same group. The rule: the optimistic value is a
**guess**, the response is the source of truth, and the response wins when the two
conflict.

#### Where Optimism Does Not Apply

Optimistic updates are not suitable for every operation. Three criteria rule them out.

**If the result cannot be predicted**, it does not apply. When the server runs a
validation, a quota check, or a conflict resolution, the client cannot know the
result, and the guess will often miss.

**If failure is routine**, it does not apply. Rollback is acceptable when it is rare;
when it is frequent, the screen looks like it is constantly correcting itself, and the
user cannot trust anything.

**If the operation cannot be undone**, it does not apply. Showing success on screen for
a payment, an external notification, or a permanent deletion presents something not
yet real as if it were. A waiting indicator is the correct behavior here.

The two approaches coexist in the same application: deleting a measurement and
changing a label are done optimistically; registering a station and changing a
permission are done by waiting for the response.

#### The Visibility of Failure

Rollback cannot be done silently. A row that vanished from the screen coming back is,
to the user, an unexplained event; an interface that does not say the operation failed
leaves the user believing it succeeded.

A failure notification carries three things: which operation failed, why, and how to
retry. The notification must also reach the screen reader; the focus and announcement
rules established in the Document and Events topic apply here.

If the retry happens automatically, the backoff rules from the Asynchronous JavaScript
and the Runtime course apply, and the change stays in the pending list during the
retry. The user sees an error only once the final retry has also failed.

#### Summary

- An optimistic update reflects the expected result onto the screen before the
  response; its gain is not in network duration but in the perception of waiting and in
  the interface's simplification.
- Snapshot-based rollback stores the screen's result at one moment, so it also erases
  the result of another change that arrived in between.
- The pending mutation layer keeps the server base separate and recomputes the screen
  every time; rollback is removing a single change from the list.
- A successful response is written into the base, or the relevant keys are
  invalidated; the optimistic value is a guess, and it loses to the server response
  when the two conflict.
- Optimism does not apply to operations whose result cannot be predicted, that fail
  often, or that cannot be undone; a waiting indicator is the correct behavior.
- Rollback is never done silently: which operation failed, why, and how it will be
  retried are reported to the user.

#### Next Step

Where state belongs and how it is updated has been settled, but all of it lives in
memory. When the user closes the tab, the interface theme, the selected unit system,
and a half-filled measurement form disappear; when the page reloads, the application is
built up from scratch. The storage layers from the Browser and Web Platform course
provide the place to keep this, but which state is persisted, how a stored record is
read once the code has changed, and what two tabs writing to the same storage produce
are separate questions. The next lesson covers persisting state.

### Persistent State

Source: https://academia.sh/en/courses/frontend-architecture/persistent-state

Writing state to storage — which slice gets persisted, per-slice versioning and the migration chain, merging with defaults, diluting the cost of writes, the collision between two tabs, and establishing the initial state.

Where state belongs and how it is updated has been settled, but all of it lives in
memory. In the North Slope Measurement Station application, when the user closes the
tab, the interface theme, the selected unit system, and the recently viewed stations
disappear.

The Storage APIs lesson in the Browser and Web Platform course established the place to
keep it: the lifetimes of the layers, the string store's type losses, quota, and schema
migration. This lesson sits the state layer on top of that place and answers four
separate questions: which slice is written, how a record is read once the code has
changed, when it is written, and what happens if two tabs write at the same time.

#### Which Slice Gets Persisted

The criterion is the conjunction of two conditions: a value is persisted if it
**cannot be reproduced** and the user **will notice its loss**. The interface theme
meets both; the user chose it once and does not want to choose it again on every
launch.

Four kinds fall outside this. **Server state** is not persisted — its source is remote
and the copy's freshness is already a separate layer's job; old data shown at the next
launch, without a freshness window, is wrong data. **URL state** is not persisted; it
is already carried in the address, and if it is written to two places, which one wins
becomes ambiguous. **Derived value** is not persisted; its inputs are stored instead.
**Identity tokens and personal data** are not persisted either; per the rule
established in the Storage APIs lesson, any code running on the page can read that
storage.

What remains is a portion of shared client state and the entries the user has left
half-finished.

#### Per-Slice Versioning

Stored data outlives the code that wrote it. The Storage APIs lesson already
established the solution: a record carries a version field, and the read path passes
through a **migration chain** that carries an old version forward to the current one.

The state layer needs one addition. If a single version number covers the entire
record, changing a single slice's shape bumps the whole record's version and forces a
migration step to be written for every slice. Because slices evolve independently of
each other, the version is also kept **per slice**.

```js
// persistent-state.mjs — per-slice versioning, migration chain, cross-tab merging
const SCHEMA = {
  preferences: {
    version: 3,
    defaults: { theme: "light", unit: "metric", rowHeight: "normal" },
    migrate: {
      1: (d) => ({ ...d, unit: "metric" }),                       // 1 -> 2: unit added
      2: ({ darkTheme, ...d }) => ({ ...d, theme: darkTheme ? "dark" : "light" }),  // 2 -> 3
    },
  },
  recentlyViewed: { version: 1, defaults: [], migrate: {} },
};

function persist(state) {                    // only the slices written in the schema are written
  const record = {};
  for (const [name, k] of Object.entries(SCHEMA)) record[name] = { version: k.version, data: state[name] };
  return JSON.stringify(record);
}

function restore(raw) {
  const notes = [];
  let record;
  try { record = JSON.parse(raw); } catch { return { state: defaults(), notes: ["unparseable"] }; }

  const state = {};
  for (const [name, k] of Object.entries(SCHEMA)) {
    const slice = record[name];
    if (!slice || typeof slice.version !== "number") { state[name] = k.defaults; notes.push(`${name}: missing`); continue; }
    if (slice.version > k.version) { state[name] = k.defaults; notes.push(`${name}: future version`); continue; }
    let data = slice.data, v = slice.version;
    while (v < k.version) { data = k.migrate[v](data); v += 1; }
    if (v !== slice.version) notes.push(`${name}: ${slice.version} -> ${k.version}`);
    state[name] = Array.isArray(k.defaults) ? data : { ...k.defaults, ...data };
  }
  for (const name of Object.keys(record))
    if (!(name in SCHEMA)) notes.push(`${name}: not in schema, dropped`);
  return { state, notes };
}

const defaults = () =>
  Object.fromEntries(Object.entries(SCHEMA).map(([name, k]) => [name, k.defaults]));

console.log("-- restore --");
const EXAMPLES = [
  ['{"preferences":{"version":3,"data":{"theme":"dark","unit":"metric","rowHeight":"compact"}},"recentlyViewed":{"version":1,"data":["north-slope"]}}', "current record"],
  ['{"preferences":{"version":1,"data":{"darkTheme":true}},"recentlyViewed":{"version":1,"data":[]}}', "two versions behind"],
  ['{"preferences":{"version":3,"data":{"theme":"dark"}}}', "missing slice + missing field"],
  ['{"preferences":{"version":9,"data":{}},"recentlyViewed":{"version":1,"data":["east-ridge"]}}', "future version"],
  ['{"preferences":{"version":3,"data":{}},"session":{"version":1,"data":{"token":"abc"}}}', "slice no longer written"],
  ['{preferences:}', "corrupt content"],
];
for (const [raw, label] of EXAMPLES) {
  const { state, notes } = restore(raw);
  console.log(label.padEnd(32), JSON.stringify(state));
  console.log(" ".repeat(32), "notes:", notes.join("; ") || "-");
}

console.log("-- two tabs --");
const A = { preferences: { theme: "dark", unit: "metric", rowHeight: "normal" }, recentlyViewed: ["north-slope"] };
const B = { preferences: { theme: "light", unit: "metric", rowHeight: "normal" }, recentlyViewed: ["east-ridge", "west-valley"] };

// Writing the whole record: whoever writes last overwrites the other's slice too.
console.log("A wrote            ", persist(A));
console.log("B wrote (whole)    ", persist(B));

// Per-slice write: each tab writes only the slice it changed.
function writeSlice(raw, name, data) {
  const record = JSON.parse(raw);
  record[name] = { version: SCHEMA[name].version, data };
  return JSON.stringify(record);
}
const after = writeSlice(persist(A), "recentlyViewed", B.recentlyViewed);
console.log("B wrote (slice)    ", after);
console.log("A's theme held:", restore(after).state.preferences.theme);
```

```
-- restore --
current record                   {"preferences":{"theme":"dark","unit":"metric","rowHeight":"compact"},"recentlyViewed":["north-slope"]}
                                 notes: -
two versions behind              {"preferences":{"theme":"dark","unit":"metric","rowHeight":"normal"},"recentlyViewed":[]}
                                 notes: preferences: 1 -> 3
missing slice + missing field    {"preferences":{"theme":"dark","unit":"metric","rowHeight":"normal"},"recentlyViewed":[]}
                                 notes: recentlyViewed: missing
future version                   {"preferences":{"theme":"light","unit":"metric","rowHeight":"normal"},"recentlyViewed":["east-ridge"]}
                                 notes: preferences: future version
slice no longer written          {"preferences":{"theme":"light","unit":"metric","rowHeight":"normal"},"recentlyViewed":[]}
                                 notes: recentlyViewed: missing; session: not in schema, dropped
corrupt content                  {"preferences":{"theme":"light","unit":"metric","rowHeight":"normal"},"recentlyViewed":[]}
                                 notes: unparseable
-- two tabs --
A wrote             {"preferences":{"version":3,"data":{"theme":"dark","unit":"metric","rowHeight":"normal"}},"recentlyViewed":{"version":1,"data":["north-slope"]}}
B wrote (whole)     {"preferences":{"version":3,"data":{"theme":"light","unit":"metric","rowHeight":"normal"}},"recentlyViewed":{"version":1,"data":["east-ridge","west-valley"]}}
B wrote (slice)     {"preferences":{"version":3,"data":{"theme":"dark","unit":"metric","rowHeight":"normal"}},"recentlyViewed":{"version":1,"data":["east-ridge","west-valley"]}}
A's theme held: dark
```

The migration chain ran two steps on the second row. In the oldest shape, the theme
was kept as a boolean field; across two passes, the unit was added first, then the
field was renamed and its value converted. Because the slice's version advances on its
own, the other slice was never touched.

#### The Read Boundary

The remaining rows show the read side's defenses, and all of them follow from the same
principle: everything coming from storage is external input.

A missing slice falls back to its default. A **missing field** is also filled with its
default; in the third row, even though only the theme was written, the unit and row
height took their default values. This merge prevents old records from being
considered invalid when a new field is added; adding a field requires no migration
step.

A slice with a future version cannot be carried backward, so it falls back to the
default — the user may have used a newer version on another device. Unparseable
content is discarded entirely. A slice not found in the schema, however, is dropped;
this prevents an old field that is no longer persisted — a session token in the
example — from continuing to sit in storage.

This last case is also a cleanup task. When a slice is removed from the persistence
list, the old record in storage must also be deleted; merely stopping the writes does
not remove the data sitting there.

#### Write Timing

The Storage APIs lesson established that the first two layers' access is synchronous
and that large records can block the main thread. The state layer is a candidate to
write on every change; this can mean code that writes to storage on every keystroke.

Three rules bound the cost. Writes are **debounced**: a short quiet period is awaited
after a change, and a single write is made. Only the **changed slice** is written; if
preferences did not change, that slice is untouched. And the record is kept **small**;
growing data is moved to structured storage with an asynchronous interface.

A final write may be wanted as the tab closes. The only method that reliably works at
the moment the page closes is a synchronous write, and a long operation may not
complete at that moment; for this reason, writing at the moment of change is preferred
over relying on closure.

#### Two Tabs

The last section shows a loss scenario. Two tabs of the same origin share the same
storage. In the first tab the theme has been switched to dark; in the second, the
recently viewed list has changed.

When the second tab writes the **whole record**, the first tab's theme reverts to
light: the second tab's in-memory copy is from before the theme change. When it writes
only the slice it changed, both changes are preserved; the output's last line confirms
this.

Per-slice writing narrows the collision, it does not eliminate it: when two tabs change
the same slice at once, the last writer wins. Where this is acceptable, nothing further
is done. Where it is not, the storage event introduced in the Storage APIs lesson is
listened for and the other tab's change is loaded into memory; the next write is then
made on top of the current record.

#### Establishing the Initial State

The state read from storage as the application starts becomes the initial value of the
in-memory state. Order matters: if the read happens before the first render, the
screen opens with the correct theme. If it happens after, the application first renders
with the default theme, then switches to the stored one, and the user sees a jump.

When an asynchronous storage layer is used, this jump looks unavoidable. There are two
solutions: keeping the small preferences that cause the jump in the synchronous layer,
or delaying the first render until the record is read. The second solution lengthens
the blank-screen time, so it is generally chosen only where the first one falls short.

#### Summary

- Client state that cannot be reproduced and whose loss the user will notice is
  persisted; server state, URL state, derived values, and identity tokens are not.
- Version is kept per slice; a shape change in one slice does not require writing a
  migration step for other slices.
- The read side is defensive: a missing slice and a missing field are filled with
  defaults, future-versioned and unparseable content is ignored, and a slice not in the
  schema is dropped and deleted.
- Adding a field requires no migration step, because reading merges with defaults;
  renaming a field or changing its type does.
- Writes are debounced, only the changed slice is written, and the record is kept
  small; a write at the moment of closing is not relied upon.
- Two tabs writing the whole record overwrite each other's slice; per-slice writing
  prevents this, and a storage event is listened for to catch a collision within the
  same slice.

#### Next Step

Where state belongs, how it is updated, and how it persists have all been settled. One
layer's interior, however, is still empty: the server state cache says "make the
request, write the response," but it does not say how the request is made, in what
form the response is expected, or how an error is represented. When a request fails,
what does the layer end up with — a status code, an error body, a network outage? These
three cannot be handled the same way. The next topic covers data access, and its first
lesson, REST Client, builds the design of the request layer and the error contract
that will hold everywhere in the application.

## Data Access

The application's layer for talking to the server — the request layer's error contract, cache normalization in query-based access, data that arrives as a stream, a view-level state machine, and a retry scheme against network instability.

### REST Client

Source: https://academia.sh/en/courses/frontend-architecture/rest-client

The request layer as the application's single gateway for talking to the server — the four classes of the error contract, mapping status codes to errors, cancellation and timeouts, and the design of resource addresses.

The State Management topic settled where the application keeps which information and
how that information survives after the page closes. The container is ready: local
state sits inside the component, shared state in a common store, the state carried in
the address in the route, and whatever needs to persist in storage. But the largest part
of that container is still empty — server state.

In the North Slope Measurement Station interface, the station list, each station's
measurement history, and new measurement records are all held on the server. This lesson
covers the **contract** by which that data is fetched: who builds the request, how the
response is resolved, and what the view is told when something goes wrong. The answer to
that question is not a function but a layer.

#### Why the Request Layer Is a Separate Tier

When the code that fetches data is written directly inside a component, four things get
repeated at every call site: assembling the base address, attaching common headers,
resolving the response, and interpreting the error. The repetition is not only a cost in
typing; each repetition produces its own behavior. In one place a 404 counts as an empty
list, in another it counts as an error; one place has a timeout, another does not.

The **request layer** is a thin intermediary that gathers these four responsibilities
into one place. It makes the view two promises: every call either returns data or an
error object conforming to the **error contract** — no third option. Below the layer
sits HTTP, above it sit application concepts. The code that asks for the station list
does not know about status codes; it only sees "not found" or "no access."

The layer's boundary is just as clear. The request layer **does not hold a cache**, does
not retry, and does not manage a loading indicator. Those are, respectively, the jobs of
server state management, the resilience scheme, and the view state machine; later
lessons build each of them separately. The layer's only job is to turn the network's
uncertainty into a closed, countable set of outcomes.

#### The Error Contract

A request can fail in four distinct places, and the four are not the same thing.

- **Network error.** The request never reached the other side: the name could not be
  resolved, the connection could not be established, the connection dropped. The server
  may not have seen this request — but it may have; the response getting lost on the way
  back looks like the same error. This uncertainty will be decisive in the retry
  decision.
- **HTTP error.** The request arrived, the server answered, and the response reports
  failure. What the server thinks is written in the status code, and this information is
  reliable.
- **Parse error.** The response arrived, the status code says success, but the body is
  not in the expected shape: malformed JSON, a missing field, a different content type.
  An intervening proxy returning an HTML error page falls into this class.
- **Cancellation.** The request was stopped because nobody is waiting for the result
  anymore. The user left the page, typed another letter into the search box, or the
  timeout expired. This is not a failure but a decision; no error is shown to the user.

The four need a shared representation, or every call site reinvents its own distinction.
The contract consists of these fields: `type` (one of the four classes), `code` (a
constant name the application recognizes), `status` (the HTTP status code, if any),
`retryable` (boolean), and `detail` (the structured explanation the server sent). The
message text is not kept in the contract — which language and tone it is written in is
the view's decision; the Internationalization lesson closes that gap.

#### Mapping Status Codes to Errors

Mapping is a table's job, and the table lives in a single place. The status code classes
introduced in the How the Internet Works course's HTTP Request and Response lesson turn
into an application decision here: `4xx` says the problem is with the request itself,
and repeating the same request does not change the outcome; `5xx` says the server
could not complete it at that moment, and repeating it may work.

```js
// status-mapping.mjs — from HTTP status code to the error contract
const MAPPING = [
  [400, "malformed_request", false, "Request body could not be read"],
  [401, "no_auth",           false, "Session required"],
  [403, "no_permission",     false, "No access to this record"],
  [404, "not_found",         false, "Record not found"],
  [409, "conflict",          false, "Record changed in the meantime"],
  [422, "validation",        false, "Field values were rejected"],
  [429, "rate_limit",        true,  "Request rate exceeded"],
  [500, "server",            true,  "Server could not complete the request"],
  [503, "unavailable",       true,  "Service temporarily unavailable"],
];

function httpError(status, detail = null) {
  const row = MAPPING.find(([s]) => s === status);
  if (row) {
    const [, code, retryable, message] = row;
    return { type: "http", status, code, message, retryable, detail };
  }
  // Unknown code: the decision follows its class, no code is invented.
  const serverClass = status >= 500;
  return {
    type: "http",
    status,
    code: serverClass ? "server" : "request",
    message: serverClass ? "Server could not complete the request" : "Request was not accepted",
    retryable: serverClass,
    detail,
  };
}

for (const status of [401, 404, 422, 429, 500, 418, 504]) {
  const h = httpError(status);
  console.log(
    String(h.status).padEnd(4),
    h.code.padEnd(18),
    "retry:", String(h.retryable).padEnd(5),
    h.message,
  );
}
```

```
401  no_auth            retry: false Session required
404  not_found          retry: false Record not found
422  validation         retry: false Field values were rejected
429  rate_limit         retry: true  Request rate exceeded
500  server             retry: true  Server could not complete the request
418  request            retry: false Request was not accepted
504  server             retry: true  Server could not complete the request
```

The last two rows show the table's real value. Codes missing from the table are still
resolved to a decision that follows their class's meaning; the layer does not collapse
with "unknown code" and leave the view in an undefined state. 429 is an interesting
exception: it is in the 4xx class but retryable, because the problem lies not in the
request's content but in its timing.

#### The Layer, End to End

The file below both sets up a local server and runs the request layer against it; it
runs on its own and closes the server at the end. Because the port is given as zero, the
operating system picks a free one — that number varies by the machine running it and
does not appear in the output.

```js
// request-layer.mjs — local server + request layer, in a single file
import http from "node:http";

// --- Server: station list, measurement history, and error routes -----------
const STATIONS = [
  { code: "NS-01", name: "North Slope", elevation: 1840 },
  { code: "NS-02", name: "North Slope Ridge", elevation: 2110 },
];

const server = http.createServer((req, res) => {
  const url = new URL(req.url, "http://127.0.0.1");
  const send = (status, body, type = "application/json") => {
    res.writeHead(status, { "content-type": type });
    res.end(typeof body === "string" ? body : JSON.stringify(body));
  };
  if (url.pathname === "/stations") return send(200, STATIONS);
  if (url.pathname === "/stations/NS-99")
    return send(404, { code: "station_not_found", message: "NS-99 is not registered" });
  if (url.pathname === "/measurements" && req.method === "POST")
    return send(422, { code: "validation", fields: { value: "out_of_range" } });
  if (url.pathname === "/report")
    return send(500, "<html>server error</html>", "text/html");
  if (url.pathname === "/slow")
    return setTimeout(() => send(200, { ready: true }), 300);
  return send(404, { code: "no_route", message: "Unknown path" });
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const BASE = `http://127.0.0.1:${server.address().port}`;

// --- Error contract -----------------------------------------------------
const MAPPING = new Map([
  [401, ["no_auth", false]],       [403, ["no_permission", false]],
  [404, ["not_found", false]],     [422, ["validation", false]],
  [429, ["rate_limit", true]],     [500, ["server", true]],
  [503, ["unavailable", true]],
]);

const makeError = (type, code, retryable, status = null, detail = null) =>
  ({ type, code, retryable, status, detail });

// --- Request layer ----------------------------------------------------------
async function request(path, options = {}) {
  const { method = "GET", body = null, timeout = 1000, base = BASE } = options;
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort("timeout"), timeout);

  let response;
  try {
    response = await fetch(base + path, {
      method,
      headers: body ? { "content-type": "application/json" } : {},
      body: body ? JSON.stringify(body) : undefined,
      signal: controller.signal,
    });
  } catch {
    // The underlying layer's message depends on the environment; we surface our own code.
    return { ok: false, error: controller.signal.aborted
      ? makeError("cancel", String(controller.signal.reason), false)
      : makeError("network", "connection", true) };
  } finally {
    clearTimeout(timer);
  }

  const contentType = response.headers.get("content-type") ?? "";
  let parsed = null;
  if (contentType.startsWith("application/json")) {
    try { parsed = await response.json(); }
    catch { return { ok: false, error: makeError("parse", "unparseable", false, response.status) }; }
  } else {
    await response.text();
  }

  if (!response.ok) {
    const [code, retryable] = MAPPING.get(response.status) ?? [
      response.status >= 500 ? "server" : "request", response.status >= 500];
    return { ok: false, error: makeError("http", code, retryable, response.status, parsed) };
  }
  return { ok: true, data: parsed };
}

// --- Call sites --------------------------------------------------------
const log = (label, s) => console.log(
  label.padEnd(22),
  s.ok ? `ok    ${JSON.stringify(s.data)}`
       : `error type=${s.error.type} code=${s.error.code} status=${s.error.status} ` +
         `retry=${s.error.retryable}`);

log("station list", await request("/stations"));
log("unknown station", await request("/stations/NS-99"));
log("measurement submission", await request("/measurements", { method: "POST", body: { value: 900 } }));
log("report (not JSON)", await request("/report"));
log("timeout", await request("/slow", { timeout: 50 }));
log("same path, ample time", await request("/slow", { timeout: 1000 }));

// A closed port: the network error path.
const empty = http.createServer();
await new Promise((resolve) => empty.listen(0, "127.0.0.1", resolve));
const closedBase = `http://127.0.0.1:${empty.address().port}`;
await new Promise((resolve) => empty.close(resolve));
log("server not responding", await request("/stations", { base: closedBase }));

server.close();
```

```
station list           ok    [{"code":"NS-01","name":"North Slope","elevation":1840},{"code":"NS-02","name":"North Slope Ridge","elevation":2110}]
unknown station        error type=http code=not_found status=404 retry=false
measurement submission error type=http code=validation status=422 retry=false
report (not JSON)      error type=http code=server status=500 retry=true
timeout                error type=cancel code=timeout status=null retry=false
same path, ample time  ok    {"ready":true}
server not responding  error type=network code=connection status=null retry=true
```

Seven rows show three of the four error classes. Three points are worth noting.

First, **the response body is consumed in every case**. If a failed response's body is
left unread, the connection is not freed; even a non-JSON body is read as text and
discarded. The fifth row's 500 response returns HTML — the layer does not try to make
sense of it, it decides from the status code.

Second, the 422 response's body is carried into the `detail` field. The Validation
Schemas lesson, which will feed field-level validation errors back to the form, will use
this field; the request layer does not interpret the content, only carries it.

Third, cancellation and network errors fall into the same `catch` block but are
classified separately. What makes the distinction is not the caught value but **the
state of the abort signal**. Because the cancellation reason is carried on the signal,
it can also answer "who canceled it"; here the reason is the string `timeout`.

#### Cancellation, Timeouts, and Races

The **abort signal** introduced in the Asynchronous JavaScript and the Runtime course is
the backbone of this layer. The same mechanism serves two separate needs: giving up
after a while, and giving up because the result is no longer wanted.

A timeout keeps a request from staying open forever when the server does not respond. An
indefinite request is, from the user's point of view, a loading indicator that never
finishes. The right duration depends on the nature of the work — short for the station
list, long for a large report — which is why the layer takes the duration per call.

The second need is subtler. If the station list has a filter field, a request is
produced every time the user types. If the third request's response arrives before the
second's, the screen is left with a stale result. Half of the solution is canceling the
previous request when a new one starts; the layer accepting an abort signal from outside
makes this possible. The other half — ignoring a response that arrives late — sits on the
view side and is covered in the Loading and Error States lesson.

A canceled request does not surface to the view as an error. This is the only reason the
layer keeps the `cancel` class separate: when the call site sees this outcome, it does
nothing, because a new request will already fill the screen.

#### Resource Addresses and the Contract's Boundary

REST's meaning from the layer's point of view is plain: every resource has an address,
and the HTTP method says what to do. The collection is `/stations`, a single record is
`/stations/NS-01`, a nested collection is `/stations/NS-01/measurements`. Filtering,
sorting, and pagination criteria are carried in the address's query part; they determine
the resource's view, not its identity.

This arrangement has two direct consequences on the client side. First, **the address is
a cache key**: two requests to the same address ask for the same record and can share
the result. Second, **the method determines retryability**. The distinction between safe
and idempotent methods defined in the How the Internet Works course does its work here:
repeating a read request is harmless, sending a measurement record twice can produce two
records.

The contract's boundary also appears at this point. The client has to conform to
whatever level of detail the server returns resources at. If the measurement history
screen shows the station name, the last measurement, and the measurement list together,
three separate requests may be needed; three round trips for one screen directly
multiplies latency. If the server designs combined responses per screen to avoid this,
the resource model becomes tied to the screen instead, and every new screen wants a new
endpoint.

#### Summary

- The request layer takes the responsibilities of the base address, common headers,
  body resolution, and error interpretation away from the view and gathers them in one
  place; caching, retrying, and a loading indicator are not its job.
- The error contract defines four classes: network, HTTP, parse, and cancellation. In a
  network error, whether the request reached the server is unknown; in an HTTP error,
  the server's decision is known.
- Status code mapping is kept in a single table, and codes not in the table are resolved
  according to the meaning of their class; 429 is retryable even though it is in the
  4xx class.
- A failed response's body is also consumed; the structured explanation the server sends
  is carried, uninterpreted, in the `detail` field.
- The abort signal covers both timing out and giving up; a canceled request does not
  surface to the view as an error.
- A resource's address is also a cache key; whether the HTTP method is safe and
  idempotent determines retryability.

#### Next Step

This lesson's last section left a tension: what the screen wants is not shaped like what
the resource returns. Firing three requests for the measurement history screen is
costly, and so is opening a screen-specific endpoint. The root of the problem is that
**the server decides what gets requested**. The next lesson looks at a query language
that hands this decision to the client: the client writes which fields it wants and gets
them in a single request. In exchange, two new problems arise — how the nested response
that comes back is cached, and how the same field set is shared across screens.

### GraphQL Client

Source: https://academia.sh/en/courses/frontend-architecture/graphql-client

Field selection moving to the client, naming repeated field sets with fragments, normalizing the nested response into an identity-based store, and partial success joining the error contract.

The previous lesson ended on a tension: the fields the resource returns do not line up
with the fields the screen wants. The North Slope station card shows the station's
name, its last measurement, and its last two measurement rows together; in a REST
resource model that means three separate addresses. The alternative — a screen-specific
endpoint — ties the server to the interface's shape.

This lesson takes an approach that relocates the decision. In GraphQL, the server
publishes a **schema**, and the client writes which fields it wants from that schema.
One endpoint, one request, exactly the fields asked for. The gain is clear; the cost is
two new problems the client must now solve on its own: sharing the same field set across
screens, and storing the nested response that comes back.

#### Field Selection Moving to the Client

A query document selects a subtree of the fields in the schema. If `name` and
`elevation` are requested under the `station(code: "NS-01")` field, the response carries
only those two; if `lastMeasurement` is requested, it comes in the same request. Nested
fields are gathered in a single round trip, so the per-screen multiplier on latency
disappears.

Three structural differences affect the request layer. First, **the address is no
longer distinguishing**: every query goes to the same endpoint, usually sent with POST.
The previous lesson's "the address is a cache key" rule no longer holds; the key now
comes from the query document and its variables combined.

Second, **success and failure can coexist in the same response**. If a field in the
query cannot be resolved, the HTTP status stays 200 and the body carries both a partial
`data` and an `errors` list. This is a case the previous lesson's four-class error
contract does not cover, and it is addressed at the end of this lesson.

Third, **the query's cost is in the client's hands**. A deep nested selection can
produce extra reads per record on the server; selecting a list field under another list
field in the schema multiplies the cost. The server reins this in with depth and
complexity limits; on the client side, the counterpart is asking only for the fields the
screen actually shows.

#### Fragment: Naming a Field Set

The same field set appears on multiple screens. A measurement row wants the same five
fields on both the station card and the measurement history list. Writing that set by
hand into every query means some screens fall behind whenever a field is added.

A **fragment** is a field set named on a type. A query calls the fragment by name, and
its definition is attached when the document is assembled. The file below does this
stitching as plain string processing: it finds fragment spreads, walks the dependencies
recursively, and places each definition into the document exactly once.

```js
// fragment-stitching.mjs — building a document from query fragments
const FRAGMENTS = {
  StationSummary: {
    on: "Station",
    fields: ["__typename", "code", "name", "elevation"],
  },
  MeasurementRow: {
    on: "Measurement",
    fields: ["__typename", "id", "time", "value", "unit"],
  },
  StationCard: {
    on: "Station",
    fields: ["...StationSummary", "lastMeasurement { ...MeasurementRow }"],
  },
};

// Finds fragment spreads in a field list: every word shaped like "...Name".
const spreadNames = (lines) =>
  lines.flatMap((l) => [...l.matchAll(/\.\.\.(\w+)/g)].map((m) => m[1]));

function requiredFragments(roots, collected = new Set()) {
  for (const name of roots) {
    if (collected.has(name)) continue;          // the same fragment is not written twice
    const fragment = FRAGMENTS[name];
    if (!fragment) throw new Error(`undefined fragment: ${name}`);
    collected.add(name);
    requiredFragments(spreadNames(fragment.fields), collected); // nested dependencies
  }
  return collected;
}

function buildDocument(name, fields) {
  const names = requiredFragments(spreadNames(fields));
  const definitions = [...names].map((n) => {
    const f = FRAGMENTS[n];
    return `fragment ${n} on ${f.on} {\n  ${f.fields.join("\n  ")}\n}`;
  });
  return [`query ${name} {\n  ${fields.join("\n  ")}\n}`, ...definitions].join("\n\n");
}

const document = buildDocument("StationPage", [
  "station(code: \"NS-01\") {",
  "  ...StationCard",
  "  measurements(last: 2) { ...MeasurementRow }",
  "}",
]);

console.log(document);
console.log("---");
console.log("fragments in the document:", (document.match(/^fragment /gm) ?? []).length);
```

```
query StationPage {
  station(code: "NS-01") {
    ...StationCard
    measurements(last: 2) { ...MeasurementRow }
  }
}

fragment StationCard on Station {
  ...StationSummary
  lastMeasurement { ...MeasurementRow }
}

fragment StationSummary on Station {
  __typename
  code
  name
  elevation
}

fragment MeasurementRow on Measurement {
  __typename
  id
  time
  value
  unit
}
---
fragments in the document: 3
```

`MeasurementRow` is called from two places in the document — once directly from the
query, once from inside `StationCard` — but its definition is written once. That is the
real payoff of deduplication: the field set lives in one place.

A fragment's real design value is that **the need for a field is declared next to the
code that uses it**. The code that renders a measurement row writes which fields it
needs in its own fragment; the page query does not know that, it only calls it. When a
new field is added to a screen, a single place changes, and every query using that
fragment starts receiving it.

Placing `__typename` in every fragment is not arbitrary. The store in the next section
keys records by the combination of type and id; without type information in the
response, no key can be produced.

#### Cache Normalization

The response is a nested tree. The same measurement record shows up both under
`lastMeasurement` and in the `measurements` list; stored as two copies, one can be
updated while the other goes stale. **Normalization** turns the tree into a flat,
identity-keyed store: each record lives once, and its place in the tree is left holding
a reference to it.

```js
// normalization.mjs — turning a nested response into a flat store, merging, and reading back
const ID_FIELD = { Station: "code", Measurement: "id" };

const cacheKey = (obj) => {
  const field = ID_FIELD[obj?.__typename];
  return field && obj[field] != null ? `${obj.__typename}:${obj[field]}` : null;
};

function normalize(value, store) {
  if (Array.isArray(value)) return value.map((e) => normalize(e, store));
  if (value === null || typeof value !== "object") return value;

  const fields = {};
  for (const [name, sub] of Object.entries(value)) fields[name] = normalize(sub, store);

  const id = cacheKey(value);
  if (!id) return fields;                                  // an unidentified object stays in place
  store[id] = { ...(store[id] ?? {}), ...fields };          // merge is field by field
  return { __ref: id };
}

// Reading always takes a selection; the store is cyclic, so walking it
// without a selection would never terminate.
function read(selection, value, store) {
  if (Array.isArray(value)) return value.map((e) => read(selection, e, store));
  if (value === null || typeof value !== "object") return value;
  const source = value.__ref ? store[value.__ref] : value;
  if (!source) return { __missing: value.__ref };           // not in the store
  const result = {};
  for (const [name, sub] of Object.entries(selection)) {
    if (!(name in source)) { result[name] = { __missing: name }; continue; }
    result[name] = sub === true ? source[name] : read(sub, source[name], store);
  }
  return result;
}

const store = {};
const roots = {};

// --- First query: station card + last two measurements ---------------------
const response1 = {
  station: {
    __typename: "Station", code: "NS-01", name: "North Slope", elevation: 1840,
    lastMeasurement: {
      __typename: "Measurement", id: "m-114", time: "2026-01-14T06:00Z",
      value: -4.2, unit: "C",
      station: { __typename: "Station", code: "NS-01", name: "North Slope" },
    },
    measurements: [
      { __typename: "Measurement", id: "m-114", time: "2026-01-14T06:00Z", value: -4.2, unit: "C" },
      { __typename: "Measurement", id: "m-113", time: "2026-01-13T06:00Z", value: -1.8, unit: "C" },
    ],
  },
};
roots['station(code:"NS-01")'] = normalize(response1.station, store);

console.log("store keys       :", Object.keys(store).join("  "));
console.log("NS-01             :", JSON.stringify(store["Station:NS-01"]));
console.log("m-114             :", JSON.stringify(store["Measurement:m-114"]));

// --- Second query: another screen wants the same measurement with different fields --
const response2 = { measurement: { __typename: "Measurement", id: "m-114", value: -4.4, recordedBy: "observer-3" } };
roots['measurement(id:"m-114")'] = normalize(response2.measurement, store);

console.log("--- after the second response ---");
console.log("m-114             :", JSON.stringify(store["Measurement:m-114"]));
console.log("entry count       :", Object.keys(store).length);

// --- The first screen is re-read from the store with its own selection -----
const CARD = {
  code: true, name: true,
  lastMeasurement: { id: true, value: true, unit: true },
  measurements: { id: true, value: true },
};
console.log("--- first screen's new view ---");
console.log(JSON.stringify(read(CARD, roots['station(code:"NS-01")'], store)));

// --- A screen requesting a field the store does not carry --------------------
const REPORT = { code: true, lastMeasurement: { id: true, deviation: true } };
console.log("--- screen requesting a missing field ---");
console.log(JSON.stringify(read(REPORT, roots['station(code:"NS-01")'], store)));
```

```
store keys       : Station:NS-01  Measurement:m-114  Measurement:m-113
NS-01             : {"__typename":"Station","code":"NS-01","name":"North Slope","elevation":1840,"lastMeasurement":{"__ref":"Measurement:m-114"},"measurements":[{"__ref":"Measurement:m-114"},{"__ref":"Measurement:m-113"}]}
m-114             : {"__typename":"Measurement","id":"m-114","time":"2026-01-14T06:00Z","value":-4.2,"unit":"C","station":{"__ref":"Station:NS-01"}}
--- after the second response ---
m-114             : {"__typename":"Measurement","id":"m-114","time":"2026-01-14T06:00Z","value":-4.4,"unit":"C","station":{"__ref":"Station:NS-01"},"recordedBy":"observer-3"}
entry count       : 3
--- first screen's new view ---
{"code":"NS-01","name":"North Slope","lastMeasurement":{"id":"m-114","value":-4.4,"unit":"C"},"measurements":[{"id":"m-114","value":-4.4},{"id":"m-113","value":-1.8}]}
--- screen requesting a missing field ---
{"code":"NS-01","lastMeasurement":{"id":"m-114","deviation":{"__missing":"deviation"}}}
```

The output says four things.

**The store is flat and holds no duplicates.** In the first response, measurement
`m-114` appeared in two places; the store holds a single record, and every other place
holds an `__ref` reference. The station was deduplicated the same way: the station
object embedded inside the measurement did not produce a separate record, it merged with
the existing one.

**Merging is field by field.** The second query asked for only two of the measurement's
fields, but the store record kept all five and added the newly arrived `recordedBy`
field. The new value, `-4.4`, overwrote the old one. A partial response does not prune
the stored record.

**One screen's data is refreshed by another screen's request.** The station card did not
issue a new request; because the store changed, the value the card reads became `-4.4`.
A non-normalized cache would have reflected that update only on the second screen.

**Reading requires a selection.** The store is cyclic: a station refers to a
measurement, a measurement refers back to its station. Walking it without a selection
never terminates. The reader has to know, at every level, which fields are wanted — so
the query document is needed not only at request time but at read time too. The last
line shows the follow-through: a field the store does not hold is reported as
**missing**, and at that point a server round trip is required.

#### Limits of Normalization

The store only deduplicates records that have an identity. Objects without one — a
total, pagination info, a computed summary — stay inside the record they belong to. If a
type in the schema has no id field, or the field name changes from type to type, the
mapping table (`ID_FIELD` here) needs manual upkeep.

The second limit is lists. `measurements(last: 2)` and `measurements(last: 20)` are two
different results of the same field and cannot be stored under a single value. This is
why list fields are keyed **together with their arguments**. Pagination also needs a
merge rule: does the new page overwrite the previous one, or get appended? The store
cannot know — the application has to say.

The third limit is deletion. When a measurement is deleted, dropping the store record
is not enough; the lists that reference it also need cleaning up. Otherwise reads return
`__missing` and a gap appears on screen. Which root fields get invalidated after a
mutation is this context's counterpart to the invalidation rule from the State
Management topic.

#### Partial Success and the Error Contract

The previous lesson's contract recognized four classes: network, HTTP, parse,
cancellation. Query-based access brings a fifth: **transport succeeded, a field
failed**. The response returns 200, some of the requested fields inside `data` are
`null`, and the `errors` list says which path failed with what.

Treating this response as either a single "error" or a single "success" is wrong either
way. The request layer must carry three things separately: the partial data obtained,
the failed field paths, and whether that failure should be shown to the user. If the
station name arrived but the last-measurement field came back empty, the card can still
be drawn, with an error notice in place of the measurement section. Dropping the whole
screen into an error state throws away the data that was obtained.

The distinction is made with one question: **which fields does the screen need to be
meaningful?** The server cannot answer this; the code that draws the screen knows which
field is dispensable. So partial success is not something the request layer can decide
on its own — it is carried through, and the decision is made at the call site.

Alongside field-level errors there are also protocol-level errors: an invalid query
document, a field not in the schema, a type mismatch. These are reported before the
query ever runs, and they are not a user error but a program error; there is no message to
show the user, and they need to be caught during development.

#### Summary

- In query-based access, field selection moves to the client; one endpoint and one
  request make the round trips per screen independent of the screen's complexity.
- The address is no longer distinguishing; the cache key is produced from the
  combination of the query document and its variables.
- A fragment is a field set named on a type; the need for a field is declared next to
  the code that uses it, and its definition is written into the document once.
- Normalization turns the nested response into a flat store keyed by the combination of
  type and id; merging is field by field, and one screen's request refreshes another
  screen's data.
- The normalized store is cyclic; reading always takes a selection, and a field the
  store does not hold is reported as missing.
- Partial success is a fifth error state: the partial data, the failed field paths, and
  the necessity decision are carried separately; the screen knows which field is
  dispensable.

#### Next Step

Up to this point, data has always arrived the moment the client asked for it: the page
opened, a request went out, the response was handled. The North Slope station, though,
produces a measurement without anyone asking. If a new record is created while the
measurement history screen is open, the user should not have to refresh the page to see
it. This reverses the direction of data flow: the client still opens the connection, but
the server decides the timing. The next lesson covers the two carriers of this reversed
flow — the one-way event stream and the two-way socket — and how incoming events get
applied to the store.

### Real-Time Data

Source: https://academia.sh/en/courses/frontend-architecture/real-time-data

Carrying data the server produces on its own to the client — the choice among polling, event streams, and sockets; parsing the stream format; resuming a dropped connection by id; and applying incoming events to state.

Up to this point, data has always arrived the moment the client asked for it. The North
Slope station, though, produces a measurement without anyone asking: a sensor takes a
reading every ten minutes, and the server processes the record. If the user has to
refresh the page for a record created while the measurement history screen is open to
appear, the screen lags behind reality.

This lesson covers transports that reverse the direction of that flow. Their common
point is this: the client always opens the connection — a browser cannot be reached by an
incoming connection from outside — but the server decides when to send data. What
differs is how long the connection lasts and how many directions it carries.

#### Three Transport Paths

| Path | Direction | Connection | Reconnection | Good fit |
|---|---|---|---|---|
| Polling | client asks | new each time | not needed | infrequent, latency-tolerant data |
| Long polling | client asks | open until a response | after every response | events rare, latency matters |
| Event stream | server pushes | one long HTTP response | built in | one-way notification stream |
| Socket | two-way | one long connection | set up manually | frequent, mutual communication |

**Polling** means firing a request at fixed intervals. It is the cheapest setup and uses
the request layer from the previous two lessons as is. The cost sits in two places: a
request is fired even when nothing changed, and worst-case latency equals the interval.
A screen polled every thirty seconds sees an event no later than thirty seconds after it
happens. Conditional request headers — the validators introduced in the Browser and Web
Platform course — can cut down body transfer, but not the number of requests.

**Long polling** means the server holds the response open until data exists. Request
count drops to event count, and latency disappears. In exchange, a new request has to be
opened after every event, and the server has to keep open requests pending.

**Event stream (Server-Sent Events)** means a single HTTP response stays open without
closing. The server keeps writing text chunks into that same response body, and the
client reads them as they arrive. Because it is an ordinary HTTP response, it is
compatible with intermediaries — proxies, compression, authentication headers. But it is
one-way only: the client cannot send anything over this channel, and fires a separate
request whenever it wants to send something.

**Socket (WebSocket)** is a two-way channel that starts over HTTP and then switches
protocol. Either side can send a message whenever it wants. For work that needs mutual
interaction — collaborative editing, a live cursor, a game — it is the only option.

The rule of choice is plain: if data flows in one direction, an event stream is enough
and needs fewer moving parts. If the client also needs to send messages frequently over
the same channel, a socket is set up.

#### The Shape of the Event Stream

The stream is a line-based text format. Each line writes `field: value`, and a blank
line completes the event. Four fields are defined: `event` gives the event's name,
`data` its body, `id` its sequence identifier, `retry` the reconnection interval. A line
starting with a colon is a comment and is ignored — the heartbeat that keeps the
connection alive is sent this way.

The file below both sets up a server that produces a stream and runs the parser against
it. The operating system picks the port; that number varies by the machine running it
and does not appear in the output.

```js
// event-stream.mjs — local server producing text/event-stream + stream parser
import http from "node:http";

// --- Server: publishes everything after the last id -------------------------
const MEASUREMENTS = [
  { id: 1, code: "NS-01", value: -4.2 },
  { id: 2, code: "NS-02", value: -6.1 },
  { id: 3, code: "NS-01", value: -4.4 },
  { id: 4, code: "NS-02", value: -5.9 },
];

const server = http.createServer((req, res) => {
  const lastId = Number(req.headers["last-event-id"] ?? 0);
  res.writeHead(200, {
    "content-type": "text/event-stream",
    "cache-control": "no-store",
    connection: "keep-alive",
  });
  res.write("retry: 3000\n\n");            // suggested reconnection interval
  res.write(": stream open\n\n");          // comment line: the heartbeat
  for (const m of MEASUREMENTS.filter((m) => m.id > lastId)) {
    res.write(`id: ${m.id}\n`);
    res.write("event: measurement\n");
    res.write(`data: ${JSON.stringify(m)}\n\n`);
  }
  res.end();
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const BASE = `http://127.0.0.1:${server.address().port}`;

// --- Stream parser: produces events from field lines ------------------------
function parser(apply) {
  let buffer = "";
  let event = { name: "message", data: [], id: null, retry: null };
  const reset = () => { event = { name: "message", data: [], id: null, retry: null }; };

  return function feed(chunk) {
    buffer += chunk;
    let cut;
    while ((cut = buffer.indexOf("\n")) !== -1) {
      const line = buffer.slice(0, cut);
      buffer = buffer.slice(cut + 1);

      if (line === "") {                        // blank line: dispatch the event
        if (event.data.length > 0) apply({ ...event, data: event.data.join("\n") });
        else if (event.retry !== null) apply({ ...event, data: null });
        reset();
        continue;
      }
      if (line.startsWith(":")) continue;       // comment line is ignored

      const colon = line.indexOf(":");
      const field = colon === -1 ? line : line.slice(0, colon);
      let value = colon === -1 ? "" : line.slice(colon + 1);
      if (value.startsWith(" ")) value = value.slice(1);

      if (field === "event") event.name = value;
      else if (field === "data") event.data.push(value);
      else if (field === "id" && !value.includes("\0")) event.id = value;
      else if (field === "retry" && /^\d+$/.test(value)) event.retry = Number(value);
    }
  };
}

// --- Consumer -----------------------------------------------------------
async function consumeStream(lastId, max) {
  const received = [];
  const controller = new AbortController();
  let done = false;
  const feed = parser((event) => {
    if (done) return;                       // enough received: keep parsing the rest
    received.push(event);
    if (received.filter((e) => e.data !== null).length >= max) {
      done = true;
      controller.abort("enough");
    }
  });

  const response = await fetch(`${BASE}/stream`, {
    headers: lastId ? { "last-event-id": String(lastId) } : {},
    signal: controller.signal,
  });
  const decoder = new TextDecoder();
  try {
    for await (const chunk of response.body) feed(decoder.decode(chunk, { stream: true }));
  } catch {
    if (!controller.signal.aborted) throw new Error("stream broke");
  }
  return received;
}

const log = (e) => console.log(
  `  name=${e.name.padEnd(11)} id=${String(e.id).padEnd(4)}` +
  ` retry=${String(e.retry).padEnd(4)} data=${e.data}`);

console.log("first connection (canceled after the first two events):");
const first = await consumeStream(0, 2);
first.forEach(log);

const lastReceivedId = first.filter((e) => e.id !== null).at(-1).id;
console.log(`last received id: ${lastReceivedId}`);

console.log("reconnection (with Last-Event-ID):");
const second = await consumeStream(lastReceivedId, 99);
second.forEach(log);

// --- Applying events to the store: repeated ids are ignored -----------------
const store = new Map();
const seen = new Set();
let skipped = 0;
for (const event of [...first, ...second, ...first]) {  // the first batch repeats on purpose
  if (event.data === null || event.name !== "measurement") continue;
  if (seen.has(event.id)) { skipped++; continue; }
  seen.add(event.id);
  const m = JSON.parse(event.data);
  store.set(m.code, m);
}
console.log("events processed:", seen.size, "| duplicates skipped:", skipped);
console.log("final state     :", JSON.stringify([...store.values()]));

server.close();
```

```
first connection (canceled after the first two events):
  name=message     id=null retry=3000 data=null
  name=measurement id=1    retry=null data={"id":1,"code":"NS-01","value":-4.2}
  name=measurement id=2    retry=null data={"id":2,"code":"NS-02","value":-6.1}
last received id: 2
reconnection (with Last-Event-ID):
  name=message     id=null retry=3000 data=null
  name=measurement id=3    retry=null data={"id":3,"code":"NS-01","value":-4.4}
  name=measurement id=4    retry=null data={"id":4,"code":"NS-02","value":-5.9}
events processed: 4 | duplicates skipped: 2
final state     : [{"id":3,"code":"NS-01","value":-4.4},{"id":4,"code":"NS-02","value":-5.9}]
```

#### Accepting the Disconnect

A long-lived connection drops. The wireless network changes, an intervening proxy
closes an idle connection, a laptop wakes from sleep. This is not an error but the normal
condition of stream-based work; the design is not aimed at preventing the drop but at
resuming from the right place afterward.

The field that enables resumption is `id`. The client stores the last id it received,
sends it back with the `Last-Event-ID` header when reconnecting, and the server
publishes everything after that id. The output shows this flow: the first connection was
cut after two events, the second connection started from three and four. For this
mechanism to work, the server has to **keep the events it published for a while**; if it
does not, events from the gap are lost and the client has to pull the full state again
after reconnecting.

The `retry` field suggests a reconnection interval in milliseconds. Code that writes its
own parser should honor this value and apply a growing wait instead of a fixed interval;
if every client comes back at the same interval when the server crashes, it knocks down
the server that is trying to come back up. This calculation itself is the subject of the
next lesson.

The last two lines show a third rule: **the same event can arrive twice**. During
reconnection the server may interpret the last id inclusively, or the client may drop
before it saves the id it received. Processing code therefore has to be **idempotent**:
applying the same event twice must give the same result as applying it once. In the
example this is achieved by keeping seen ids in a set; the first batch's repeat was
ignored and the final state did not change.

The second route to idempotence is writing the event as an **announcement**, not a
command. "Increment the measurement counter by one" breaks when applied twice; "station
NS-01's last measurement is `m-114`, value −4.4" gives the same result applied twice.
Events carried over a stream are designed in the second form whenever possible.

#### The Two-Way Socket's Differences

A socket starts with an ordinary HTTP request, and if the server accepts, the connection
switches protocol. From that moment HTTP semantics — method, status code, headers — no
longer apply; the channel carries text or binary messages. The application has to define
its own message format; which field says the message type, where the sequence number
sits, is left to an agreement between client and server.

It departs from the event stream on four points. **Reconnection is not built in**;
detecting a drop and rebuilding the connection is the application's job. **A drop is not
always noticed**; when transmission is cut silently, the connection can keep looking
open, so both sides send a poll message at regular intervals and wait for a reply,
treating the connection as dead if none arrives. **Queuing responsibility sits with the
client**: messages meant to be sent while the connection is down wait in a queue,
drained once the connection is up — or deliberately dropped. **Authentication happens at
the initial handshake**; no header can be sent once the channel is open, so the
session's validity for the channel's lifetime is a separate problem, covered in the
Session Renewal lesson.

#### Applying the Stream to State

An incoming event has no meaning by itself; its meaning forms from the state it is
applied to. Three problems come up in every application.

**Meeting the initial state with the stream.** When a screen opens, the naive approach
fetches the full list first, then listens to the stream. Events produced in the interval
between the two steps are missed. The right order is reversed: open the stream first and
buffer incoming events, then fetch the full list, apply the buffered events that come
after the list's timestamp, and drain the buffer.

**Out-of-order arrival.** If events arrive by different routes — one from the stream,
one from the response to the user's own action — an older value can overwrite a newer
one. The fix is for every record to carry a version or timestamp, and for the processing
code to accept **only a newer stamp**.

**The hidden tab.** Keeping the stream open in a background tab consumes a connection on
the server and produces work on the client for no benefit. The stream is closed when
visibility changes and reopened from the last id once the tab comes back to the front.
This is also a natural instance of using the missed-event recovery path.

#### Summary

- The client always opens the connection; transports differ in how long it lasts and how
  many directions it carries. Polling is the cheapest setup, and its latency equals the
  interval.
- An event stream is one-way and an ordinary HTTP response; a socket is two-way and set
  up by switching protocol, leaving reconnection and liveness checking to the
  application in exchange.
- The stream format is line-based: `event`, `data`, `id`, and `retry` fields, events
  completed by a blank line, a heartbeat sent as a comment line.
- Disconnecting is normal; the last event id is stored and reported at reconnection with
  `Last-Event-ID`. If the server does not keep its published events for a while,
  resumption is not possible.
- The same event can arrive twice; processing code must be idempotent, and events should
  be designed as final-state announcements rather than increment commands.
- When meeting the initial list with the stream, the stream is opened first and events
  are buffered; out-of-order events are filtered out with a version stamp.

#### Next Step

The last three lessons built the ways of getting data; what they share is that data is,
for a while, **not yet there**. The screen has to show something in the meantime: an
empty frame, a loading indicator, stale data, or an error. Keeping these states as
separate logical flags — loading, has error, has data — quickly produces combinations
that contradict each other. The next lesson reduces the states a view can be in to a
finite set and tests the transitions with a table.

### Loading and Error States

Source: https://academia.sh/en/courses/frontend-architecture/loading-and-error-states

Reducing the states a view can be in while it waits for data to a finite set — the contradictions logical flags produce, the transition table, filtering race conditions with a stamp, and each state's counterpart on screen.

The previous three lessons built the ways of getting data. What they share is that data
is, for a while, not yet there. The measurement history screen cannot sit empty in the
meantime: it has to show something. What gets shown depends on **which state the screen
is in at that moment** — is it loading for the first time, does it have stale data, did
the request fail, did the result come back empty.

This lesson reduces those states to a finite set. The reduction is not a matter of
tidiness: as shown below, tracking the states as separate logical flags produces
combinations that contradict each other, and those combinations show up on screen as
real defects.

#### The Contradiction Logical Flags Produce

The most commonly used starting point is three flags: `loading`, `error`, `data`. Three
booleans have eight combinations. Only four of them are meaningful.

- Loading true, data empty, no error — initial load. Meaningful.
- Loading false, data present, no error — ready. Meaningful.
- Loading false, data empty, error present — failed. Meaningful.
- Loading true, data present, no error — refreshing. Meaningful.
- Loading true, error present — a request is in flight but an error is shown. A
  spinning loading indicator and an error box sit side by side on screen.
- Loading false, data empty, no error — says nothing. The screen is empty; the user
  cannot tell whether the request has not started or has finished with no results.
- Data present, error present — which one gets shown? This decision gets made again in
  every component, and made differently.

The flags' problem is that **they can change independently of each other**. A single
line that forgets to clear the old error when a new request starts produces the fifth
combination. The defect is invisible at compile time and only surfaces during a specific
sequence of clicks.

The fix is replacing the three flags with a single field: the view is in exactly one
state at a time, and only defined transitions exist between states. An invalid
combination becomes unrepresentable this way.

#### Seven States and a Transition Table

The states a data-fetching screen needs are: **empty** (not yet requested), **loading**
(waiting with no data yet), **ready** (data present), **empty result** (the request
succeeded and the result set is empty), **refreshing** (refreshing while data is already
present), **error** (no data present, the request failed), and **stale** (old data
present, the refresh failed).

Separating the last two states is the core of this design. Turning the screen into an
error box when a refresh fails while there is data to show means taking away information
the user already has. The stale state keeps the old data on screen and reports the
refresh failure with a separate notice.

```js
// view-state.mjs — view state machine and transition table
const STATES = ["empty", "loading", "ready", "emptyResult", "refreshing", "error", "stale"];
const EVENTS = ["request", "gotData", "gotEmpty", "failed", "cancel"];

// Data-dependent targets are written as functions.
const readyIfNonEmpty = (u) => (u.data.length > 0 ? "ready" : "emptyResult");

const TABLE = {
  empty:       { request: "loading" },
  loading:     { gotData: "ready", gotEmpty: "emptyResult", failed: "error", cancel: "empty" },
  ready:       { request: "refreshing" },
  emptyResult: { request: "refreshing" },
  refreshing:  { gotData: "ready", gotEmpty: "emptyResult", failed: "stale", cancel: readyIfNonEmpty },
  error:       { request: "loading" },
  stale:       { request: "refreshing" },
};

function transition(unit, event, payload = null) {
  const target = TABLE[unit.state]?.[event];
  if (target === undefined) return { ...unit, ignored: unit.ignored + 1 };
  const next = { ...unit };
  if (event === "gotData") next.data = payload;
  if (event === "gotEmpty") next.data = [];
  if (event === "failed") next.error = payload;
  if (event === "gotData" || event === "gotEmpty") next.error = null;
  next.state = typeof target === "function" ? target(next) : target;
  return next;
}

// --- Printing the transition table -----------------------------------------
const cell = (s, e) => {
  const t = TABLE[s]?.[e];
  return t === undefined ? "·" : typeof t === "function" ? "data-dependent" : t;
};
console.log("state".padEnd(12) + EVENTS.map((e) => e.padEnd(16)).join(""));
for (const s of STATES) {
  console.log(s.padEnd(12) + EVENTS.map((e) => cell(s, e).padEnd(16)).join(""));
}

// --- Testing sequences -------------------------------------------------------
const MEASUREMENT = [{ id: "m-114", value: -4.2 }];
const SEQUENCES = [
  ["initial load succeeds",  [["request"], ["gotData", MEASUREMENT]]],
  ["initial load empty",     [["request"], ["gotEmpty"]]],
  ["initial load fails",     [["request"], ["failed", "network"]]],
  ["retry after failure",    [["request"], ["failed", "network"], ["request"], ["gotData", MEASUREMENT]]],
  ["refresh failure",        [["request"], ["gotData", MEASUREMENT], ["request"], ["failed", "server"]]],
  ["refresh canceled",       [["request"], ["gotData", MEASUREMENT], ["request"], ["cancel"]]],
  ["invalid order",          [["gotData", MEASUREMENT], ["cancel"], ["request"]]],
];

console.log("");
for (const [name, events] of SEQUENCES) {
  let unit = { state: "empty", data: [], error: null, ignored: 0 };
  const trace = ["empty"];
  for (const [event, payload] of events) {
    unit = transition(unit, event, payload);
    trace.push(unit.state);
  }
  console.log(
    name.padEnd(22), trace.join(" -> ").padEnd(46),
    `data=${unit.data.length} error=${unit.error} ignored=${unit.ignored}`);
}

// --- Race guard: a late response is ignored ---------------------------------
console.log("");
let unit = { state: "empty", data: [], error: null, ignored: 0 };
let stamp = 0;
const request = () => { stamp += 1; unit = transition(unit, "request"); return stamp; };
const respond = (ownStamp, event, payload) => {
  if (ownStamp !== stamp) { console.log(`  stamp ${ownStamp}: stale, ignored`); return; }
  unit = transition(unit, event, payload);
  console.log(`  stamp ${ownStamp}: applied -> ${unit.state}`);
};

const s1 = request();                       // search for "nor"
const s2 = request();                       // search for "north"
respond(s2, "gotData", [{ id: "m-114" }, { id: "m-113" }]);
respond(s1, "gotData", [{ id: "m-001" }]);   // late-arriving stale response
console.log("final state:", unit.state, "| records:", unit.data.length);
```

```
state       request         gotData         gotEmpty        failed          cancel
empty       loading         ·               ·               ·               ·
loading     ·               ready           emptyResult     error           empty
ready       refreshing      ·               ·               ·               ·
emptyResult refreshing      ·               ·               ·               ·
refreshing  ·               ready           emptyResult     stale           data-dependent
error       loading         ·               ·               ·               ·
stale       refreshing      ·               ·               ·               ·

initial load succeeds  empty -> loading -> ready                      data=1 error=null ignored=0
initial load empty     empty -> loading -> emptyResult                data=0 error=null ignored=0
initial load fails     empty -> loading -> error                      data=0 error=network ignored=0
retry after failure    empty -> loading -> error -> loading -> ready  data=1 error=null ignored=0
refresh failure        empty -> loading -> ready -> refreshing -> stale data=1 error=server ignored=0
refresh canceled       empty -> loading -> ready -> refreshing -> ready data=1 error=null ignored=0
invalid order          empty -> empty -> empty -> loading             data=0 error=null ignored=2

  stamp 2: applied -> ready
  stamp 1: stale, ignored
final state: ready | records: 2
```

#### What the Table Shows

The dots in the table mark **undefined transitions**, and that is where the real
information is. The `failed` event cannot arrive while in the `ready` state, because
there is no request in flight while ready. This is not a rule explicitly forbidden
somewhere in the code; it is a cell the table leaves blank. The seventh sequence tests
exactly this: a response and a cancellation arriving without a request having been fired
were both ignored, and the counter shows two. In a flag-based implementation, those same
two events would have silently corrupted the state.

The fifth and sixth sequences show two different exits from the `refreshing` state. On
failure it moved to `stale` and kept `data=1` — the old measurement list stayed on
screen. On cancellation it returned to `ready` and the error field stayed empty; the
previous lesson's rule is at work here — cancellation is not an error.

The fourth sequence shows recovery after an error. Only `request` leaves the `error`
state; that is the counterpart of a "try again" button. Clearing the error field when a
new request starts happens in one single place inside the transition function, not at
every call site.

The state machine's side benefit is testability. The seven sequences above are a unit
test suite; the view's behavior can be verified without ever rendering the screen. This
separation is not possible with flags scattered through a component.

#### Race Condition and the Stamp

The last section closes the view-side half of the problem left open in the REST Client
lesson. The user typed "nor" into the station filter, then "north"; two requests went
out. The second request was answered first, the first arrived late. If the late response
is applied, the screen shows "nor" results while the filter box reads "north."

The fix is a **request stamp**: every new request increments a counter and carries its
own stamp. When a response arrives, its stamp is compared with the currently valid
stamp; if they do not match, the response is not applied. The output's last three lines
show this: the second request was applied, the first was treated as stale, and two
records remained on screen.

A stamp does not replace canceling the request; the two are used together. Cancellation
frees the network resource but does not stop a request that cannot be canceled — one whose
response is already on its way, for example — from arriving late. The stamp is that
last line of defense.

The same mechanism can also be built per key: instead of a counter, "which filter this
was for" is stored, and an incoming response is dropped if it does not match its own key.
In server state management the cache key already does this job; the stamp provides the
same protection for screens that do not use a cache.

#### Each State's Counterpart on Screen

Once the set of states is fixed, each state's visual counterpart is a separate decision.
The Interface Design Fundamentals course's Loading and Empty States and Error and
Warning States lessons set the criteria for these decisions; only the parts tied to the
state machine are noted here.

What gets shown in the **loading** state depends on the shape of the expected content.
For a list whose structure is known, a placeholder skeleton reserves the space the
incoming content will fill, and no shift occurs once the content arrives. For a result
whose structure is unknown, a neutral indicator is enough.

The **refreshing** state must not look the same as `loading`. Turning the screen into a
skeleton while data is already present takes away the content the user is reading. The
right presentation leaves the existing content in place and reports the refresh with a
small indicator off to the side.

**Empty result** and **error** call for separate messages. An empty result says the
system worked correctly and no record met the criteria; the recovery path is changing
the criteria. An error says the system could not respond; the recovery path is trying
again. Presenting the two in the same box steers the user toward the wrong action.

The **stale** state carries two pieces of information at once: the data on screen is
valid but may not be current. It is presented by leaving the data in place and adding a
strip that reports the refresh failed and when the last successful update happened. The
"try again" action lives on that strip.

Timing itself is also a decision. Showing the loading indicator when the response
arrives very fast produces a momentary flicker on screen; the indicator is delayed to
appear only after a short grace period. Once it has appeared, it also stays on screen for
at least a short minimum, or the same flicker happens on exit. Both thresholds sit
outside the state machine: the machine says `loading`, and the presentation layer
decides when to draw the indicator.

#### Summary

- Tracking loading, error, and data as separate logical flags produces eight
  combinations; half are contradictory, and those contradictions show up on screen as
  real defects.
- A single-field state machine makes invalid combinations unrepresentable; the empty
  cells in the table mark undefined transitions, and an unexpected event is silently
  ignored.
- A refresh failing while data is present is a separate state; instead of turning the
  screen into an error box, the old data is kept and the refresh failure is reported
  separately.
- Empty result and error suggest different recovery paths: one is changing the
  criteria, the other is trying again.
- A request stamp filters out a late response; it does not replace cancellation, it
  works alongside it.
- When the loading indicator appears and how long it stays at minimum are decisions of
  the presentation layer; the state machine only says which state it is in.

#### Next Step

The state machine treated the `failed` event as a single gate: an error arrived, the
state changed, the user was shown a "try again" button. But some errors should not be
shown to the user at all. A request cut off on a weak connection can succeed when
retried two hundred milliseconds later; the user sees nothing in between. Which errors
get retried automatically, how long to wait between attempts, and why that wait needs to
be randomized is the subject of the next lesson.

### Retry and Backoff

Source: https://academia.sh/en/courses/frontend-architecture/retry-and-backoff

Which errors get retried automatically, the math of exponential backoff and jitter, the server's suggested wait, the retry budget, and a circuit breaker that stops sending the request after consecutive failures.

The previous lesson treated the `failed` event as a single gate: an error arrived, the
view moved to the error state, the user was shown a "try again" button. But some errors
should not be shown to the user at all. An observer connecting to the North Slope station
from the mountain has a connection that drops and recovers within seconds; a request
retried two hundred milliseconds later succeeds, and the user sees nothing in between.

This lesson's question is: which error gets retried automatically, how long to wait, how
many times to try, and when to give up. Answering all four wrong produces the same
outcome — an already-strained server gets strained further by its own clients.

#### Which Error Gets Retried

The decision looks at two pieces of information: the error's class and the request's
method.

The error class was established in the REST Client lesson. In **HTTP errors**, the
server's decision is known: a 4xx is about the request itself, and repeating the same
request produces the same response. There are two exceptions — 429 reports a rate
limit, 5xx the server's current state; both can change over time.

In **network errors**, whether the server received the request is unknown. The response
may have been lost on the way back; in that case the server has already processed the
request. So for a network error, the decision falls to the method. The **safe** methods
defined in the How the Internet Works course do not change state, and repeating them is
harmless. A request that submits a measurement record, on the other hand, can produce
two records if processed twice; it cannot be retried automatically.

There is a way around this limit: an **idempotency key** generated by the client is
attached to the request, and the server does not count a second request with the same
key as a new record. Sending the key makes a state-changing request retryable too. This
is a contract the client and server must agree on together; it cannot be set up
unilaterally.

**Parse errors** and **cancellation** are never retried under any circumstance. The
first is the program's own bug, the second is already a decision to give up.

#### Computing the Wait Duration

Retrying immediately does not help: the problem needs time to clear. A fixed interval
is not enough either; if the server cannot get back up, it keeps taking the same load at
the same frequency. **Exponential backoff** grows the wait duration by a multiplier on
every attempt; recovery is fast if the problem is short, and the client backs off if
it is long.

Exponential growth alone is unbounded, so a **cap** is set. The real missing piece is
something else. When a server goes down, every client connected to it gets an error at
the same moment; if they all apply the same formula, they all retry at the same moment.
The instant the server comes back up, it meets a synchronized wave and goes back down.
**Jitter** spreads out that wave by adding randomness to the wait duration.

```js
// backoff.mjs — wait durations and jitter's effect on distribution
// Deterministic pseudo-random generator: the same seed gives the same sequence.
function makeRng(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const BASE = 200, MULTIPLIER = 2, CAP = 5000;
const exponential = (n) => Math.min(CAP, BASE * MULTIPLIER ** n);

const random = makeRng(20260114);
const fullJitter = (n) => Math.round(random() * exponential(n));

// Decorrelated jitter: the next wait is chosen from a range tied to the previous one.
let previous = BASE;
const decorrelatedJitter = () => {
  previous = Math.min(CAP, Math.round(BASE + random() * (previous * 3 - BASE)));
  return previous;
};

console.log("attempt  fixed   exponential  exp+cap  full jitter  decorrelated");
for (let n = 0; n < 6; n++) {
  console.log(
    String(n + 1).padEnd(9) +
    String(BASE).padEnd(8) +
    String(BASE * MULTIPLIER ** n).padEnd(13) +
    String(exponential(n)).padEnd(9) +
    String(fullJitter(n)).padEnd(13) +
    String(decorrelatedJitter()));
}

// --- Wake-up distribution of clients that dropped at the same time ----------
const CLIENTS = 24, ATTEMPT = 3;   // third attempt's wait: exponential(2) = 800 ms
const bucket = (ms) => Math.floor(ms / 200) * 200;

const count = (list) => {
  const m = new Map();
  for (const v of list) m.set(v, (m.get(v) ?? 0) + 1);
  return [...m.entries()].sort((a, b) => a[0] - b[0])
    .map(([k, s]) => `${String(k).padStart(4)}ms:${"#".repeat(s)}`);
};

const noJitter = Array.from({ length: CLIENTS }, () => bucket(exponential(ATTEMPT - 1)));
const rng2 = makeRng(20260114);
const withJitter = Array.from({ length: CLIENTS },
  () => bucket(Math.round(rng2() * exponential(ATTEMPT - 1))));

console.log("\nno jitter (24 clients, 3rd attempt):");
count(noJitter).forEach((s) => console.log("  " + s));
console.log("with jitter (24 clients, 3rd attempt):");
count(withJitter).forEach((s) => console.log("  " + s));
```

```
attempt  fixed   exponential  exp+cap  full jitter  decorrelated
1        200     200          200      70           384
2        200     400          400      296          1034
3        200     800          800      780          2121
4        200     1600         1600     303          1933
5        200     3200         3200     480          3986
6        200     6400         5000     957          5000

no jitter (24 clients, 3rd attempt):
   800ms:########################
with jitter (24 clients, 3rd attempt):
     0ms:######
   200ms:#####
   400ms:#######
   600ms:######
```

The two histograms show the difference plainly. In the no-jitter calculation, all
twenty-four clients pile up on a single instant; in the jittered calculation, the same
clients spread across four separate intervals. The peak load the server sees drops to a
quarter.

The table's two jitter forms make different trade-offs. **Full jitter** picks the wait
from anywhere between zero and the exponential value — the widest spread, but some
attempts land very early. **Decorrelated jitter** picks the next wait from a range tied
to the previous one; growth is smoother and less jumpy, reaching the cap by the sixth
attempt in the table. The choice depends on the service's recovery time: full jitter
suits a fast-recovering service, decorrelated jitter a slow-recovering one.

Note that the randomness is built with a deterministic generator. In production the
generator is unseeded; here the seed is held fixed so the output is the same on every
run and the table can be verified.

#### Building the Loop

The calculation alone is not enough; when the loop stops and how much it honors what the
server says also need to be defined.

```js
// retry-loop.mjs — retry loop running against a local server
import http from "node:http";

let measurementRequests = 0;
const server = http.createServer((req, res) => {
  const path = new URL(req.url, "http://127.0.0.1").pathname;
  if (path === "/measurements" && req.method === "GET") {
    measurementRequests += 1;
    if (measurementRequests <= 2) {                // the first two requests fail
      res.writeHead(503, { "content-type": "application/json" });
      return res.end(JSON.stringify({ code: "unavailable" }));
    }
    res.writeHead(200, { "content-type": "application/json" });
    return res.end(JSON.stringify([{ id: "m-114", value: -4.2 }]));
  }
  if (path === "/measurements" && req.method === "POST") {
    res.writeHead(422, { "content-type": "application/json" });
    return res.end(JSON.stringify({ code: "validation" }));
  }
  if (path === "/report") {
    res.writeHead(429, { "content-type": "application/json", "retry-after": "2" });
    return res.end(JSON.stringify({ code: "rate_limit" }));
  }
  res.writeHead(404, { "content-type": "application/json" });
  res.end(JSON.stringify({ code: "no_route" }));
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const BASE = `http://127.0.0.1:${server.address().port}`;

// --- Wait calculation (deterministic) ---------------------------------------
function makeRng(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const random = makeRng(20260114);
const wait = (n) => Math.round(random() * Math.min(5000, 200 * 2 ** n));

// --- Policy: which outcome is retried ---------------------------------------
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
function isRetryable(outcome, method) {
  if (outcome.type === "network") return SAFE_METHODS.has(method);  // unknown if it arrived
  if (outcome.type === "http") return outcome.status === 429 || outcome.status >= 500;
  return false;                                                     // parse error, cancellation
}

// --- Loop ---------------------------------------------------------------
async function retry(path, { method = "GET", maxAttempts = 4, sleep }) {
  const waits = [];
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    let outcome;
    try {
      const response = await fetch(BASE + path, { method });
      const body = await response.json();
      outcome = response.ok
        ? { type: "success", data: body }
        : { type: "http", status: response.status,
            serverSuggestion: Number(response.headers.get("retry-after") ?? 0) * 1000 };
    } catch {
      outcome = { type: "network" };
    }
    if (outcome.type === "success") return { outcome, attempt: attempt + 1, waits };
    if (!isRetryable(outcome, method)) return { outcome, attempt: attempt + 1, waits };
    if (attempt === maxAttempts - 1) return { outcome, attempt: attempt + 1, waits };

    // If the server's suggestion is longer than the computed wait, the suggestion wins.
    const w = Math.max(wait(attempt), outcome.serverSuggestion ?? 0);
    waits.push(w);
    await sleep(w);
  }
}

// The test records durations instead of actually waiting; a real
// application would have a real timer here.
const recorded = [];
const fakeSleep = (ms) => { recorded.push(ms); return Promise.resolve(); };

const log = (name, r) => console.log(
  name.padEnd(24),
  `attempt=${r.attempt}`.padEnd(11),
  `outcome=${r.outcome.type}${r.outcome.status ? "/" + r.outcome.status : ""}`.padEnd(17),
  `waits=[${r.waits.join(", ")}]`);

log("503, then success", await retry("/measurements", { sleep: fakeSleep }));
log("422 (not retried)", await retry("/measurements", { method: "POST", sleep: fakeSleep }));
log("429 (Retry-After: 2)", await retry("/report", { maxAttempts: 3, sleep: fakeSleep }));
log("404 (not retried)", await retry("/missing", { sleep: fakeSleep }));

console.log("measurement requests reaching the server:", measurementRequests);
console.log("all recorded waits                      :", recorded.join(", "));

server.close();
```

```
503, then success        attempt=3   outcome=success   waits=[70, 184]
422 (not retried)        attempt=1   outcome=http/422  waits=[]
429 (Retry-After: 2)     attempt=3   outcome=http/429  waits=[2000, 2000]
404 (not retried)        attempt=1   outcome=http/404  waits=[]
measurement requests reaching the server: 3
all recorded waits                      : 70, 184, 2000, 2000
```

The first row shows the expected behavior: the server returned 503 twice, the loop
waited twice, and data arrived on the third attempt. The user saw none of this; the view
was still in the `loading` state.

The second and fourth rows show the policy's discriminating power. The validation error
and the not-found response both finished in a single attempt, with no wait at all, since
retrying would not change the outcome. The most common retry mistake is feeding every
error into the loop: when the user enters a bad value, finding out takes three times as
long.

The third row shows **the server's suggestion winning**. The computed wait was a few
hundred milliseconds; because the server said two seconds, two seconds were waited. The
server knows its own recovery time better than the client does, so its suggestion is
used not as a floor and not as a ceiling but as a **lower bound**. If the suggestion is
unreasonably long, giving up on the attempt — telling the user the situation — beats
waiting.

The loop has three limits. An **attempt count** ceiling prevents an infinite loop. A
**total budget** limits the combined duration of all attempts; a user pressing a button
and waiting eight seconds is worse than seeing an error. Third, **user-triggered**
requests and **background** requests have separate policies: attempt count is kept low
and duration short when a user is waiting, and the reverse holds for a background
refresh.

#### Circuit Breaker

Retrying is a single request's problem. If the server is entirely unreachable, every
request runs its own attempts, and the application both keeps the user waiting and loads
the server with requests that will never succeed. A **circuit breaker** recognizes this
situation at a level above: once consecutive failures cross a threshold, it **stops
sending the request at all**.

```js
// circuit-breaker.mjs — not sending the request at all after consecutive failures
const THRESHOLD = 3, COOLDOWN = 5000;   // 3 consecutive failures; retry allowed after 5000 ms

function circuitBreaker() {
  let state = "closed", consecutiveFailures = 0, openedAt = 0;

  return {
    allow(now) {
      if (state === "open" && now - openedAt >= COOLDOWN) state = "halfOpen";
      return { allowed: state !== "open", state };
    },
    report(success, now) {
      if (success) { state = "closed"; consecutiveFailures = 0; return state; }
      consecutiveFailures += 1;
      if (state === "halfOpen" || consecutiveFailures >= THRESHOLD) {
        state = "open"; openedAt = now; consecutiveFailures = 0;
      }
      return state;
    },
  };
}

// Logical clock: advances step by step instead of real time.
const breaker = circuitBreaker();
const EVENTS = [
  [    0, false], [  200, false], [  600, false],   // the third failure opens the circuit
  [  900, false], [ 1500, false],                   // during cooldown: the request does not go out
  [ 6000, false],                                    // half-open attempt, fails
  [ 8000, false],                                    // back in cooldown
  [11500, true ],                                    // half-open attempt, succeeds
  [11800, true ],
];

let sent = 0, blocked = 0;
console.log("time    allow  prev state    result        new state");
for (const [now, success] of EVENTS) {
  const { allowed, state } = breaker.allow(now);
  if (!allowed) {
    blocked += 1;
    console.log(String(now).padEnd(8) + "no     " + state.padEnd(14) +
                "not sent".padEnd(13) + state);
    continue;
  }
  sent += 1;
  const next = breaker.report(success, now);
  console.log(String(now).padEnd(8) + "yes    " + state.padEnd(14) +
              (success ? "success" : "failure").padEnd(13) + next);
}
console.log(`sent: ${sent}  blocked: ${blocked}`);
```

```
time    allow  prev state    result        new state
0       yes    closed        failure      closed
200     yes    closed        failure      closed
600     yes    closed        failure      open
900     no     open          not sent     open
1500    no     open          not sent     open
6000    yes    halfOpen      failure      open
8000    no     open          not sent     open
11500   yes    halfOpen      success      closed
11800   yes    closed        success      closed
sent: 6  blocked: 3
```

The three states and their transitions can be read from the output. In the **closed**
state requests pass through; a third consecutive failure moves the circuit to **open**.
In the open state no request is sent — the call fails immediately and without touching
the network — three of the nine events never reached the server. Once the cooldown
elapses, the circuit becomes **half-open** and allows exactly one trial request. That
trial's outcome is decisive: on failure the circuit opens again and the cooldown
restarts (the behavior seen at the 6000 mark), on success it closes and the counter
resets.

While the circuit is open, the call site gets no response but does get an error. This
error should be kept distinct from a network error: the user is told "the service is not
responding right now," and since the "try again" button will not do anything until the
cooldown elapses anyway, reporting that duration is meaningful.

The circuit breaker's scope is also a design decision. A single breaker covering the
whole application lets one endpoint's failure stop every screen. In practice a breaker
is usually set up **per service or endpoint group**, so the station list stays readable
while the measurement-writing path is broken.

#### Summary

- The retry decision looks at error class and method: 429 and 5xx are always retried, a
  network error only for safe methods; validation errors, parse errors, and
  cancellations are never retried.
- A state-changing request is retryable only if an idempotency key generated by the
  client has been agreed on with the server.
- Exponential backoff grows the wait on every attempt and is bounded by a cap; jitter
  keeps clients that failed at the same moment from coming back at the same moment.
- Full jitter has the widest spread, decorrelated jitter smooths the growth; the choice
  depends on the service's recovery time.
- The server's reported wait suggestion is compared with the computed duration and the
  larger one is applied; a total duration budget limits things alongside the attempt
  count.
- A circuit breaker stops sending the request at all after consecutive failures; it
  moves between open, half-open, and closed states and is set up per endpoint group.

#### Next Step

The Data Access topic is complete here: data is fetched, stored, updated as a stream,
unexpected states are reduced to a finite set, and network instability is absorbed
before it reaches the user. All of this flow was one-directional — from server to
screen. The next topic sets up the reverse direction: data the user supplies. The
measurement entry form asks all at once where a field's value is held, when to validate
it, how to report an error, and what to show during submission. The first lesson opens
with the first of these questions: where does a field's value live — in the document, or
in the application's state?

## Forms and Identity

Flows that take data from the user and recognize the user: holding form state, validation schemas that run on both sides, accessible error presentation, token storage choices, session renewal, and adapting the application to language.

### Form State Management

Source: https://academia.sh/en/courses/frontend-architecture/form-state-management

Whether a field's value lives in the document or in the application's state, the notification cost of the two approaches, the information form state carries besides values, and the role of the initial value and dirtiness in the submission flow.

The Data Access topic built its flow in one direction: from the server to the screen. The
second half of the North Slope interface runs in the opposite direction. The observer opens
the measurement entry form, selects the station, types the value, adds a note, and submits
it. The first question in this flow is technically small but has large consequences:
**where does a field's current value live?**

There are two answers. The value can live in the document itself — the field holds what was
typed, and the application reads it when needed. Or the value can live in the application's
state — the field only displays the state, every keystroke updates the state, and the field
re-renders from the updated state. This lesson covers what each answer gains and costs, and
what form state carries besides values.

#### Two Sources of Truth

**Uncontrolled input** holds the value in the document. The field manages its own state; the
application supplies the initial value and does not intervene afterward. The value is read at
submission time or whenever needed. The entry list defined in the Web Fundamentals and HTML
course is this approach's natural counterpart: the browser already collects the fields on its
own.

**Controlled input** holds the value in the application's state. The field takes its
displayed value from the state and fires an event that updates the state on every change. The
application always knows the value the user sees, and can change it.

The source of the distinction is the attribute–property distinction from the Browser and the
Web Platform course: a field's initial value is the attribute declared in the markup, while
its current value is the element's property. An uncontrolled field lets the two live apart; a
controlled field equalizes them after every change.

What does being controlled make possible? **Transforming the value while it is typed** —
uppercasing it, applying a format mask, dropping a disallowed character. **Dependency between
fields** — narrowing the unit list when the station changes. **Instant validation** and
interface decisions tied to the value. And persisting an unsaved draft: the value is already
in the application's hands.

What does it make expensive? Every keystroke produces a state update and a re-render. In a
form with thirty fields, this cost becomes measurable.

#### Notification Cost

The size of the cost depends on **subscription granularity**: who gets notified when the
state changes? The model below counts two extreme approaches — fields subscribed to the whole
form state, and fields subscribed only to their own value.

```js
// subscription-granularity.mjs — notification cost at form level and field level
const FIELDS = ["station", "date", "value", "unit", "note",
                 "observer", "device", "wind", "humidity", "pressure", "snow", "description"];

function store(granularity) {                 // granularity: "form" | "field"
  const subscribers = new Map();               // path -> list of field renderers
  const renders = new Map();                   // field -> call count
  return {
    subscribe(path, field) {
      const key = granularity === "form" ? "*" : path;
      if (!subscribers.has(key)) subscribers.set(key, []);
      subscribers.get(key).push(field);
      renders.set(field, 0);
    },
    write(path) {
      const key = granularity === "form" ? "*" : path;
      for (const field of subscribers.get(key) ?? []) {
        renders.set(field, renders.get(field) + 1);
      }
    },
    total: () => [...renders.values()].reduce((t, s) => t + s, 0),
  };
}

// The user's keystroke sequence: 30 changes across three fields.
const KEYSTROKES = [
  ...Array(14).fill("description"),
  ...Array(10).fill("value"),
  ...Array(6).fill("note"),
];

const LABELS = { form: "form-level", field: "field-level" };
for (const granularity of ["form", "field"]) {
  const s = store(granularity);
  for (const f of FIELDS) s.subscribe(f, f);
  for (const path of KEYSTROKES) s.write(path);
  console.log(
    `${LABELS[granularity].padEnd(12)}:`,
    `${KEYSTROKES.length} changes →`,
    `${String(s.total()).padStart(4)} field renders`);
}

// Uncontrolled field: value stays in the document, read once at submission.
const s = store("field");
for (const f of FIELDS) s.subscribe(f, f);
console.log(`${"uncontrolled".padEnd(12)}:`, `${KEYSTROKES.length} changes →`,
            `${String(s.total()).padStart(4)} field renders`);
```

```
form-level  : 30 changes →  360 field renders
field-level : 30 changes →   30 field renders
uncontrolled: 30 changes →    0 field renders
```

The numbers show the ratio: at form granularity, every change renders twelve fields; at field
granularity, only the one that changed. The twelvefold difference grows in direct proportion
to the number of fields.

The rule that follows is not "a controlled field is expensive," but **"building a controlled
field at form granularity is expensive."** Even when the value lives in the application's
state, if the subscription is built at field granularity, the cost stays limited to a single
field. In most cases this is achieved by offering a separate read path per field, rather than
sharing the form state as a single object.

The third row is a limiting case: when the value stays in the document, no rendering happens
at all. Its cost is that the application does not know what the user typed until submission.
A mixed arrangement is also possible and common: most fields uncontrolled, with only the
fields that are interdependent or transformed while typed left controlled.

#### Form State Is Not Just Values

Even after the decision about where values live is made, the form still carries a few more
pieces of information the screen needs. When an error message appears, when the submit button
is disabled, and whether a warning appears when leaving the page all depend on this
information.

```js
// form-state.mjs — the state reducer for the measurement entry form
const INITIAL = {
  station: "NS-01", date: "2026-01-14", value: "", unit: "C", note: "",
};

const newState = () => ({
  values: { ...INITIAL },
  touched: new Set(),
  submitting: false,
  submitCount: 0,
});

const dirtyFields = (s) =>
  Object.keys(INITIAL).filter((f) => s.values[f] !== INITIAL[f]);

function reduce(s, [event, field, value]) {
  switch (event) {
    case "changed":
      return { ...s, values: { ...s.values, [field]: value } };
    case "touched":
      return { ...s, touched: new Set([...s.touched, field]) };
    case "submitStarted":
      return { ...s, submitting: true, submitCount: s.submitCount + 1,
               touched: new Set(Object.keys(INITIAL)) };
    case "submitEnded":
      return { ...s, submitting: false };
    case "accepted":                          // server accepted the record: new baseline
      return { ...newState(), submitCount: s.submitCount };
    case "reset":
      return { ...newState(), submitCount: s.submitCount };
    default:
      throw new Error(`unknown event: ${event}`);
  }
}

const EVENTS = [
  ["changed", "value", "-"],
  ["changed", "value", "-4"],
  ["changed", "value", "-4.2"],
  ["touched", "value"],
  ["changed", "note", "morning reading"],
  ["submitStarted"],
  ["submitEnded"],
  ["accepted"],
];

let s = newState();
const log = (label) => console.log(
  label.padEnd(30),
  `value=${JSON.stringify(s.values.value).padEnd(7)}`,
  `dirty=[${dirtyFields(s).join(",")}]`.padEnd(20),
  `touched=${s.touched.size}`.padEnd(11),
  `submitting=${s.submitting}`.padEnd(17),
  `submitCount=${s.submitCount}`);

log("initial");
for (const event of EVENTS) {
  s = reduce(s, event);
  log(event.filter(Boolean).join(" "));
}

// --- The leave-warning decision ---------------------------------------------
console.log("");
for (const scenario of [
  ["empty form", newState()],
  ["partially filled", reduce(newState(), ["changed", "value", "-4.2"])],
  ["after submit", s],
]) {
  const [name, scenarioState] = scenario;
  const dirty = dirtyFields(scenarioState);
  console.log(`${name.padEnd(20)} dirty=${dirty.length} → leave warning: ` +
              (dirty.length > 0 ? "shown" : "not shown"));
}
```

```
initial                        value=""      dirty=[]             touched=0   submitting=false  submitCount=0
changed value -                value="-"     dirty=[value]        touched=0   submitting=false  submitCount=0
changed value -4               value="-4"    dirty=[value]        touched=0   submitting=false  submitCount=0
changed value -4.2             value="-4.2"  dirty=[value]        touched=0   submitting=false  submitCount=0
touched value                  value="-4.2"  dirty=[value]        touched=1   submitting=false  submitCount=0
changed note morning reading   value="-4.2"  dirty=[value,note]   touched=1   submitting=false  submitCount=0
submitStarted                  value="-4.2"  dirty=[value,note]   touched=5   submitting=true   submitCount=1
submitEnded                    value="-4.2"  dirty=[value,note]   touched=5   submitting=false  submitCount=1
accepted                       value=""      dirty=[]             touched=0   submitting=false  submitCount=1

empty form           dirty=0 → leave warning: not shown
partially filled     dirty=1 → leave warning: shown
after submit         dirty=0 → leave warning: not shown
```

Four separate pieces of information appear, and each answers a different question.

**Value** is the field's content. **Touched** is whether the user has left that field; the
rule for when to show an error in the Form Events lesson rests on this information — in the
first three rows the value changed but the touched count stayed at zero, meaning the user is
still typing and showing an error would be premature.

**Dirty** means the value differs from the initial one. It is not kept as a separate flag; it
is **computed** by comparing against the initial values. If it were kept as a flag, the form
would stay incorrectly dirty after the user changed a field and then changed it back to its
original value.

**Submitting** and **submit count** are separate pieces of information. The first keeps the
button disabled; the second permanently marks that the user has attempted a submission once,
and from then on lets errors show without waiting for touched. The sixth row shows all fields
counted as touched the moment submission starts — this single line establishes the "show
every error the moment submit is pressed" behavior.

#### Initial Values, Dirtiness, and Reset

Initial values are part of the form state, not a constant baked into the code. There are two
reasons for this.

The first is that dirtiness must be computable. Without a comparison baseline, dirtiness
cannot be known. The output's last block shows where this computation connects: a user trying
to leave a form with a dirty field is shown a warning; a clean form is not. Showing the
warning unconditionally — even when the user has typed nothing — makes the interface
annoying, and it gets ignored over time.

The second is that initial values can **change**. A form editing an existing measurement gets
its initial values from the server. When the record loads, the form state must be built
against the new baseline; otherwise the loaded values look dirty, and an "unsaved change"
warning appears on an untouched form.

Three separate operations are frequently confused. **Reset** returns the values to their
initial state. **Clear** empties the values — that is not the same as returning to the
initial state; in an edit form the two produce very different results. **Rebaselining** takes
the current values as the new initial state; this is what happens once the server accepts the
record. In the output, the `accepted` event does this: the form was cleared and dirtiness
reset, but the submit count was preserved.

#### The Moment of Submission

Submission is the moment when form state is most fragile, and it requires three rules.

**Double submission is prevented.** No new submission starts while the submitting flag is
true. Visually disabling the button is not enough; a keyboard-triggered submission or a fast
double click can arrive before the button's state does, so the block is built into the state.
This is the form-side counterpart of the idempotency key from the Retry and Backoff lesson —
the block is on the client, the guarantee is on the server.

**Field errors from the server are attached to the form.** In the REST Client lesson, the
body of a 422 response carried its errors in a `detail` field; the field names in that body
match the form's fields, and errors are placed next to the matching field. If a field name
does not match, the error is shown at the form level instead of being swallowed.

**Values are preserved on a failed submission.** Emptying a form the user filled out just
because the server rejected it makes them start the work over. The values stay in place; only
the errors are added.

The perceived duration of a submission, from the user's side, is a separate matter. Assuming
the record will be accepted by the server and updating the screen right away — rolling back
if an error arrives — is the form-side application of the optimistic update from the State
Management topic. Storing an unfinished form as a draft rests on the storage decision from
the Persistent State lesson; which initial values the stored draft is compared against is
determined by the baseline rule above.

#### Summary

- A field's value is held either in the document (uncontrolled) or in the application's state
  (controlled); the distinction comes from whether the attribute and the property live apart.
- Being controlled makes transformation while typing, dependency between fields, and instant
  validation possible; its cost is the notification produced on every change.
- What determines notification cost is not being controlled but subscription granularity;
  field-level subscription cuts the render count to a twelfth in a twelve-field form.
- Form state carries touched, dirty, a submitting flag, and a submit count alongside values;
  each feeds a different interface decision.
- Dirtiness is not kept as a flag; it is computed against the initial values. Reset, clear,
  and rebaselining are three separate operations.
- At submission, double submission is blocked at the state level, field errors from the
  server are attached to the matching fields, and the user's entered values are preserved on
  failure.

#### Next Step

This lesson held the form's values but never looked at whether those values are
**acceptable**. The measurement value must be a number, must fall within a reasonable range
for the temperature, and the date must not be in the future. These rules need to be known in
two places: on the client, to give the user instant feedback, and on the server, knowing that
a request can be built without the page ever being used. Writing the same rule separately in
both places means the two will drift apart over time. The next lesson gathers the rules into
a single declaration and shows that the same declaration produces the same result on both
sides.

### Validation Schemas

Source: https://academia.sh/en/courses/frontend-architecture/validation-schemas

Gathering acceptance rules into a single declaration, transforming raw input into a parsed value, the same schema producing the same result on the client and the server, and keeping the error code separate from the message.

The previous lesson held the form's values but did not look at whether they are acceptable.
The measurement value must be a number, must fall within a reasonable range for its unit, the
date must not be in the future, and the station code must match its defined format.

These rules need to be known in two places. On the client, to give the user feedback without
going to the server. On the server, because — as the Form Events lesson said — a request can
be built without the page ever being used; client-side validation is a convenience, not a
guarantee. Writing the same rule separately in both places means the two will drift apart
over time: the client accepts, the server rejects — or the reverse, which is worse.

#### A Schema Is a Declaration

Writing validation as nested conditionals works, but it makes three things impossible:
listing the rules, running the rules in another environment, and declaring the rules to the
interface. If learning whether a field is required requires reading code, that fact cannot be
wired to the "required" mark on the screen.

A **validation schema** writes the rules as data: which rules apply to which field lives in a
structure, separate from the function that applies them. This yields three things. The schema
is **portable** — the same structure can be interpreted in another runtime. It is
**queryable** — the interface reads which field is required from the schema. And it is
**composable** — shared field rules are defined once and used across multiple forms.

This does not replace **constraint validation** from the Web Fundamentals and HTML course; it
builds on top of it. Constraints declared in markup run the browser's own mechanism and work
even if the page's script never loads. The schema's scope is broader: cross-field rules that
cannot be declared in markup, ranges that change by unit, a codebase shared with the server.

#### Not Just Validating — Parsing

A validation function usually returns true or false for "is it valid?" This forces the
calling code to redo the same conversion a second time: if the value is valid, convert the
text to a number, parse the date, trim the whitespace. A small difference between the two
conversions leads the validated value and the used value to diverge.

The more robust approach is for validation to return the **parsed value**. The rule chain
takes the raw input, transforms it at each step, and produces a ready-to-use, typed value at
the end. If the field is invalid, no value is produced; if it is valid, the produced value now
carries the rules' guarantee. This also makes it possible for both sides to reach the same
result: even when the client supplies the string `"-4,2"` and the server supplies the number
`-4.2`, the chain's output is identical.

#### The Same Schema on Both Sides

```js
// validation-schema.mjs — the same schema on both the client and the server
const NOW = Date.parse("2026-01-14T12:00:00Z");     // fixed for the example

// --- Rule library: every rule takes the raw value, returns a value or an error
const RULES = {
  required: (raw) =>
    raw === undefined || raw === null || String(raw).trim() === ""
      ? { error: "required" } : { value: raw },
  pattern: (raw, regex) =>
    new RegExp(regex).test(String(raw)) ? { value: String(raw) } : { error: "format" },
  number: (raw) => {
    const n = typeof raw === "number" ? raw : Number(String(raw).replace(",", "."));
    return Number.isFinite(n) ? { value: n } : { error: "not_a_number" };
  },
  option: (raw, allowed) =>
    allowed.includes(String(raw)) ? { value: String(raw) } : { error: "invalid_option" },
  date: (raw) => {
    const t = Date.parse(String(raw));
    return Number.isNaN(t) ? { error: "not_a_date" } : { value: t };
  },
  maxLength: (raw, n) =>
    String(raw ?? "").length <= n ? { value: String(raw ?? "") } : { error: "too_long" },
};

const SCHEMA = {
  fields: {
    station: [["required"], ["pattern", "^NS-\\d{2}$"]],
    date:    [["required"], ["date"]],
    value:   [["required"], ["number"]],
    unit:    [["required"], ["option", ["C", "F"]]],
    note:    [["maxLength", 120]],
  },
  crossFields: [
    {
      name: "range", requires: ["value", "unit"],
      check: (v) => {
        const [min, max] = v.unit === "F" ? [-76, 140] : [-60, 60];
        return v.value < min || v.value > max
          ? { path: "value", code: "out_of_range", param: { min, max } } : null;
      },
    },
    {
      name: "notFuture", requires: ["date"],
      check: (v) => (v.date > NOW ? { path: "date", code: "in_future" } : null),
    },
  ],
};

function validate(schema, input) {
  const value = {}, errors = [];
  for (const [field, rules] of Object.entries(schema.fields)) {
    let raw = input[field];
    let broken = false;
    for (const [name, param] of rules) {
      const result = RULES[name](raw, param);
      if (result.error) { errors.push({ path: field, code: result.error, param }); broken = true; break; }
      raw = result.value;                      // the parsed value feeds the next rule
    }
    if (!broken) value[field] = raw;
  }
  for (const cross of schema.crossFields) {
    if (!cross.requires.every((f) => f in value)) continue;   // skipped if input is missing
    const error = cross.check(value);
    if (error) errors.push(error);
  }
  return { valid: errors.length === 0, value, errors };
}

const log = (name, result) => {
  console.log(`${name}`);
  console.log(`  valid : ${result.valid}`);
  console.log(`  value : ${JSON.stringify(result.value)}`);
  console.log(`  errors: ${JSON.stringify(result.errors)}`);
};

// --- Same record, two different shapes --------------------------------------
// Client: every value comes from a field as a string.
const clientInput = {
  station: "NS-01", date: "2026-01-14", value: "-4,2", unit: "C", note: "morning reading",
};
// Server: the JSON body sends numbers as numbers, strings as strings.
const serverBody = {
  station: "NS-01", date: "2026-01-14", value: -4.2, unit: "C", note: "morning reading",
};

log("client input (strings)", validate(SCHEMA, clientInput));
log("server body (JSON)", validate(SCHEMA, serverBody));

const a = JSON.stringify(validate(SCHEMA, clientInput));
const b = JSON.stringify(validate(SCHEMA, serverBody));
console.log("did both sides produce the same result:", a === b);

// --- A request that never went through the form ------------------------------
log("\na request built by skipping the page", validate(SCHEMA, {
  station: "XX-9", date: "2027-03-01", value: 900, unit: "K", note: "x".repeat(200),
}));

// --- The 422 body built from the error list ----------------------------------
const result = validate(SCHEMA, { station: "NS-01", date: "2026-01-14", value: "88", unit: "C" });
const body422 = {
  code: "validation",
  fields: result.errors.reduce((t, e) => ({ ...t, [e.path]: { code: e.code, param: e.param } }), {}),
};
console.log("\n422 body:", JSON.stringify(body422));
```

```
client input (strings)
  valid : true
  value : {"station":"NS-01","date":1768348800000,"value":-4.2,"unit":"C","note":"morning reading"}
  errors: []
server body (JSON)
  valid : true
  value : {"station":"NS-01","date":1768348800000,"value":-4.2,"unit":"C","note":"morning reading"}
  errors: []
did both sides produce the same result: true

a request built by skipping the page
  valid : false
  value : {"date":1803859200000,"value":900}
  errors: [{"path":"station","code":"format","param":"^NS-\\d{2}$"},{"path":"unit","code":"invalid_option","param":["C","F"]},{"path":"note","code":"too_long","param":120},{"path":"date","code":"in_future"}]

422 body: {"code":"validation","fields":{"value":{"code":"out_of_range","param":{"min":-60,"max":60}}}}
```

#### Reading the Output

The first two blocks produced the same parsed value, and the comparison came back true. The
inputs were different: the client supplied a string using a comma as its decimal separator,
the server supplied a number. The chain converted both to the same number. The date also
resolved to the same timestamp on both sides. This equality is the practical payoff of the
schema being a single source: the client accepting a value the server rejects can only happen
if the schema has drifted apart.

The third block shows a request that skipped the form, and four errors are reported at once.
Notice that the rule chain **stops at the first error**: because the `station` field got
stuck on the pattern rule, the rules after it never ran, and the field produced no parsed
value. The reason is that a later rule relies on an earlier rule's output; a range rule that
mistook a raw value for a number would produce a meaningless error.

There is a deliberate gap in the same block: even though `value` is 900, no range error is
reported. That is because the cross-field rule requires the `unit` field to be resolved, and
`unit` stayed invalid with the value "K". **Cross-field rules are skipped when their inputs
are missing.** The alternative — using a default value for a missing input — produces
misleading errors: the reported range could change once the user fixes the unit. The request
is already rejected; the range error is reported only once the user fixes the unit and
resubmits.

The last line closes the loop: on the server, the error list from the same schema turns
directly into the body of the 422 response. In the REST Client lesson, this body was carried
in a `detail` field; in the Form State Management lesson, it was matched by field name and
attached to the form. Having the same field names on both sides is this chain's precondition.

#### Not Every Rule Can Run on Both Sides

Sharing the schema does not mean every rule can be shared. Three classes need to be
distinguished.

**Rules that run on both sides** look at the input itself: format, type, length, range,
consistency between fields. These form the body of the schema.

**Rules that can only run on the server** look at the system's state: whether the station
code is registered, whether a measurement for that date and station has already been
entered, whether the user has write authorization for that station. The client does not know
these, and should not. Their counterpart on the client is showing the field error that comes
back from the server.

**Rules meaningful only on the client** look at the input's presentational side: a field's
length counter, a warning for input that is not finished yet. These do not block submission.

There is also an intermediate class that asks the server a question: querying whether a
station code exists while the user types, for instance. This is validation becoming
asynchronous, and it requires three rules. The query is throttled with **debounce**. A
late-arriving response is filtered out by the stamp rule from the Loading and Error States
lesson. Submission goes ahead rather than waiting on a pending query, and trusts the server's
decision — asynchronous validation is a convenience, not a gate.

#### An Error Code, Not a Message

The errors the schema produces carry a code and a parameter, not text. This separation is
required for three reasons.

Text is **language-dependent**. The same error code has a different rendering in Turkish and
in English; the place that produces the code does not need to know the language. The
Internationalization lesson builds this mapping.

Text is **context-dependent**. The same `required` code is written as "This field is
required" next to the field, and as "Measurement value was not entered" in the error summary.
Using one piece of text in both places breaks one of them.

Text is **parameter-dependent**. A range error carries its limits as a parameter; the message
is written by substituting them in. When a limit changes, the schema changes, not the text.

Naming the codes is itself a contract. The client and the server must use the same set of
codes; if the server sends a code the client does not recognize, the client should show a
generic message and never print the code to the screen.

#### Summary

- A validation schema writes rules as data; this makes the rules listable, queryable, and
  runnable in another environment.
- The schema does not replace the constraint validation declared in markup; it adds the
  cross-field and state-dependent rules that markup cannot cover.
- The rule chain does not just validate; it converts raw input into a parsed value, so that
  the client's string and the server's number arrive at the same result.
- The chain stops at the first error, because a later rule relies on an earlier rule's
  output; cross-field rules are skipped when their required inputs are not resolved.
- Rules fall into three classes: those that run on both sides, those that can only run on
  the server, and those meaningful only on the client. Asynchronous validation is a
  convenience; it does not stop submission.
- An error code and a parameter are carried, not a message; text depends on language,
  context, and parameters, and is produced in the presentation layer.

#### Next Step

There is now an error code and a parameter for every field. The next question is not how these
appear on the screen — it is how they get **heard**. A user who is not looking at the screen
never sees the red outline; when the error message is written below the field, a screen
reader does not automatically read it together with the field. Where focus goes when a
submission is rejected, how the error count is announced, and how the message gets tied to
the field all call for separate decisions. The next lesson builds that connection.

### Accessible Error Presentation

Source: https://academia.sh/en/courses/frontend-architecture/accessible-error-presentation

Programmatically associating the error message with the field, announcing invalidity, building an error summary ordered by document position, moving focus, and the moment at which the message is written.

The previous lesson produced an error code and a parameter for every field. The next question
is not how these appear on the screen: an error message that only works when seen is
unfinished. When an observer navigating with a screen reader submits the measurement entry
form, they do not see the red outline; text written below the field is not automatically read
together with that field.

This lesson covers making the error **perceivable**: which element the message is tied to,
how a field's invalidity is announced, where focus goes when a submission is rejected, and at
what moment the message is written.

#### What an Error Is for a User Who Cannot See It

Visual presentation uses three cues: color, position, and icon. All three are insufficient on
their own.

**Color cannot be the only indicator.** WCAG success criterion 1.4.1 says this directly:
information cannot be conveyed by color alone. A red outline is just an ordinary outline to a
user without color perception. Text is needed alongside the color.

**Position is not a programmatic link.** A message sitting right below the field is a
relationship built by the eye. If there is no link between the two elements in the
accessibility tree, a user who focuses the field never hears the message at all. The link
must be built explicitly.

**Announcing an error and describing it are separate success criteria.** WCAG 3.3.1 requires
that the error be **identified** in text; 3.3.3 requires that, where possible, a **suggestion
for correction** be offered. "Invalid value" satisfies the first but not the second; "Enter a
value between −60 and 60" satisfies both. This is why the previous lesson's error parameters
are written into the message.

#### Tying the Message to the Field

The link is built with the field's **accessible description** list. This mechanism, defined
in the Web Fundamentals and HTML course, sets which texts get read after the label once the
field is focused. If a field has both a hint and an error, both enter the list, and the
list's **order is the reading order**.

```js
// error-markup.mjs — associating field, hint, error, and summary
const MESSAGES = {
  required:      () => "This field is required.",
  not_a_number:  () => "Enter a number; you can use a comma as the decimal separator.",
  out_of_range:  (p) => `Enter a value between ${p.min} and ${p.max}.`,
  in_future:     () => "The measurement date cannot be in the future.",
};

// Fields are defined in document order; that order drives the focus decision.
const FIELDS = [
  { name: "station", label: "Station", hint: null },
  { name: "date",    label: "Measurement date", hint: "Day-month-year format." },
  { name: "value",   label: "Measurement value", hint: "Degrees Celsius." },
  { name: "note",    label: "Note", hint: null },
];

function fieldMarkup(field, error) {
  const id = `field-${field.name}`;
  const hintId = field.hint ? `${id}-hint` : null;
  const errorId = error ? `${id}-error` : null;
  // Order matters: the hint is read first, then the error.
  const describedBy = [hintId, errorId].filter(Boolean).join(" ");

  const lines = [`<label for="${id}">${field.label}</label>`];
  lines.push(
    `<input id="${id}" name="${field.name}"` +
    (describedBy ? ` aria-describedby="${describedBy}"` : "") +
    (error ? ` aria-invalid="true"` : "") + ">");
  if (field.hint) lines.push(`<p id="${hintId}">${field.hint}</p>`);
  if (error) lines.push(`<p id="${errorId}">${MESSAGES[error.code](error.param)}</p>`);
  return lines.join("\n");
}

function summary(errors) {
  // Ordered by the field's position in the document, not by detection order.
  const ordered = FIELDS
    .map((f) => [f, errors.find((e) => e.path === f.name)])
    .filter(([, e]) => e);
  const items = ordered.map(([f, e]) =>
    `  <li><a href="#field-${f.name}">${f.label}: ${MESSAGES[e.code](e.param)}</a></li>`);
  return [
    `<div role="alert" tabindex="-1" id="error-summary">`,
    `  <h2>${ordered.length} fields need correction</h2>`,
    "  <ul>", ...items, "  </ul>", "</div>",
  ].join("\n");
}

// Error list from the schema (detection order differs from document order).
const ERRORS = [
  { path: "value", code: "out_of_range", param: { min: -60, max: 60 } },
  { path: "date", code: "in_future" },
];

console.log(summary(ERRORS));
console.log("");
for (const field of FIELDS) {
  console.log(fieldMarkup(field, ERRORS.find((e) => e.path === field.name)));
}

// --- Focus target -------------------------------------------------------------
const firstInvalid = FIELDS.find((f) => ERRORS.some((e) => e.path === f.name));
console.log("\nerror count :", ERRORS.length);
console.log("focus target:", ERRORS.length > 1 ? "#error-summary" : `#field-${firstInvalid.name}`);
console.log("if only one error:", `#field-${firstInvalid.name}`);
```

```
<div role="alert" tabindex="-1" id="error-summary">
  <h2>2 fields need correction</h2>
  <ul>
  <li><a href="#field-date">Measurement date: The measurement date cannot be in the future.</a></li>
  <li><a href="#field-value">Measurement value: Enter a value between -60 and 60.</a></li>
  </ul>
</div>

<label for="field-station">Station</label>
<input id="field-station" name="station">
<label for="field-date">Measurement date</label>
<input id="field-date" name="date" aria-describedby="field-date-hint field-date-error" aria-invalid="true">
<p id="field-date-hint">Day-month-year format.</p>
<p id="field-date-error">The measurement date cannot be in the future.</p>
<label for="field-value">Measurement value</label>
<input id="field-value" name="value" aria-describedby="field-value-hint field-value-error" aria-invalid="true">
<p id="field-value-hint">Degrees Celsius.</p>
<p id="field-value-error">Enter a value between -60 and 60.</p>
<label for="field-note">Note</label>
<input id="field-note" name="note">

error count : 2
focus target: #error-summary
if only one error: #field-date
```

Four decisions show up in the generated markup.

**The accessible description list carries both the hint and the error.** Dropping the hint
from the list once an error appears is a common mistake; the user hears the error but never
learns the expected format. The two stay together, and the hint is read first.

**Invalidity is announced separately.** Reading the message is not the same as announcing that
the field is invalid. `aria-invalid="true"` writes the field's state to the accessibility
tree; it is used alongside the message text, not in its place. There is another attribute that
does a similar job — `aria-errormessage`, which points directly at the error message — but it
only carries meaning once an invalidity announcement is given, while the
accessible-description reference works in every case. The robust path is to build on the
accessible-description reference.

**A field with no error carries no such attribute.** The first and last fields have no
`aria-invalid` written; writing `aria-invalid="false"` for a valid field is also correct but
unnecessary. What matters is that the attribute is **removed** once the error is fixed; if it
stays, the user hears a corrected field as still invalid.

**The summary is ordered by document position.** The error list coming from the schema
started with `value`, but in the summary `date` came first. When the user reads down the
summary's items, they should also move down the form in the same order; detection order is an
implementation detail and must not leak onto the screen.

#### The Summary Box and Moving Focus

When submission is rejected, the user needs to know two things: that the submission did not go
through, and what needs fixing. If no part of the page appears to have changed — especially in
a long form, where the errors ended up outside the viewport — the user assumes the button
is not working.

The **error summary** sits at the top of the form, states the error count, and each item links
to its field. Being a link matters: a user navigating by keyboard selects the item and goes
straight to the field. The item's text carries the field's label together with the message;
if only the message were written, it would not be clear which field is being referred to.

**Focus** moves to the summary box. For the box to be focusable, it needs to be marked as a
focus target that does not join the tab order — the `tabindex="-1"` in the output does this;
the element can be focused programmatically but does not take part in the tab sequence.

The behavior splits into two based on the error count. With a single error, moving focus
directly to that field saves the user a step. With more than one error, moving it to the
summary box gives the user the full picture first. The output's last three lines compute this
decision.

Moving focus has a limit: taking it somewhere the user does not expect is itself a disruption.
The rule is that focus is moved **only in response to the user's own action**. Pressing the
submit button is such an action; a request completing in the background is not.

#### The Moment of Announcement

Even when the markup is correct, **when** the message appears is a separate decision. Saying
"invalid" on every keystroke while the user is typing the measurement value declares an
unfinished input to be an error, and makes typing harder.

```js
// display-timing.mjs — when the error message is written
function isShown({ hasError, touched, submitAttempted, wasShown }) {
  if (!hasError) return false;                // nothing to show without an error
  if (submitAttempted) return true;           // everything shows after submission
  if (touched) return true;                   // the field was left
  return wasShown;                            // once shown, keep tracking it
}

// The answer is always "no" without an error; the table only counts an invalid field.
const CASES = [];
for (const touched of [false, true])
  for (const submitAttempted of [false, true])
    for (const wasShown of [false, true])
      CASES.push({ hasError: true, touched, submitAttempted, wasShown });

const yesNo = (b) => (b ? "yes" : "no");
console.log("touched  submitted  before  →  shown");
for (const c of CASES) {
  console.log(
    yesNo(c.touched).padEnd(9) + yesNo(c.submitAttempted).padEnd(11) +
    yesNo(c.wasShown).padEnd(8) + "   " + yesNo(isShown(c)));
}

// The user's actual journey: no error while typing, one on leaving, gone once fixed.
console.log("\nstep by step through one field's life");
const JOURNEY = [
  ["typed '-' into an empty field", { hasError: true,  touched: false, submitAttempted: false }],
  ["typed '-4'",                    { hasError: false, touched: false, submitAttempted: false }],
  ["field cleared",                 { hasError: true,  touched: false, submitAttempted: false }],
  ["field left",                    { hasError: true,  touched: true,  submitAttempted: false }],
  ["came back, typed '9'",          { hasError: false, touched: true,  submitAttempted: false }],
  ["made it '900'",                 { hasError: true,  touched: true,  submitAttempted: false }],
];
let shownBefore = false;
for (const [name, partial] of JOURNEY) {
  const shown = isShown({ ...partial, wasShown: shownBefore });
  shownBefore = shown;
  console.log(`  ${name.padEnd(31)} error=${yesNo(partial.hasError).padEnd(4)} → ${yesNo(shown)}`);
}
```

```
touched  submitted  before  →  shown
no       no         no         no
no       no         yes        yes
no       yes        no         yes
no       yes        yes        yes
yes      no         no         yes
yes      no         yes        yes
yes      yes        no         yes
yes      yes        yes        yes

step by step through one field's life
  typed '-' into an empty field   error=yes  → no
  typed '-4'                      error=no   → no
  field cleared                   error=yes  → no
  field left                      error=yes  → yes
  came back, typed '9'            error=no   → no
  made it '900'                   error=yes  → yes
```

The table's second row is the core of the rule: even when the field has not been left yet and
no submission has been attempted, an error that was **shown once** keeps being shown. The
consequence appears in the last two lines of the second output block — once a field has been
found invalid, the message updates instantly as the user types; it disappears once fixed, and
returns once broken again.

The behavior in the first three rows is deliberate too. When the user types a minus sign into
an empty field, the value is already invalid, but no message appears; the input is still in
progress. The message first appears once the field is left.

This rule explains why the `touched` set and the `submitCount` counter from the previous
lesson are kept separate. Both are inputs to the question "can I show this field," and each
marks a different moment.

#### Live Region or Focus?

There are two ways for text that appears on the page to be read by a screen reader: moving
focus there, or placing the text inside a **live region**. WCAG success criterion 4.1.3
requires that status messages that do not take focus be announced too.

The distinction is made with one question: does the user need to do something right now?

**Moving focus** directs the user and interrupts their flow. It is appropriate when a
submission is rejected; the user is already waiting for a result and needs to do something to
continue. The summary box in the output carries `role="alert"`; this role marks the box's
content to be announced the moment it appears, and is used together with moving focus.

A **live region** gives information without interrupting the user's flow. In a form validated
field by field, focus is not moved for every message; messages accumulate in a
politely-prioritized region and are read once the user stops typing.

Over-announcing is a real defect. A live region updated on every keystroke keeps the screen
reader talking constantly and stops the user from hearing what they are typing. That is why
field-by-field validation results are throttled with debounce, while the submission result is
delivered as a single, decisive announcement.

#### Summary

- Color cannot be the only indicator and position is not a programmatic link; the message is
  explicitly attached to the field through an accessible-description reference.
- The accessible description list carries both the hint and the error in order; the
  invalidity announcement does not replace the message, it is given alongside it, and is
  removed once the error is fixed.
- The error summary is ordered by document position, states the error count, and each item
  links to its field.
- Focus moves directly to the field when there is a single error, and to the summary box when
  there is more than one; moving it happens only in response to the user's own action.
- The message is first written once the field is left or a submission is attempted; once
  written, it updates on every change.
- Announcements that do not interrupt focus are given through a live region; a region updated
  on every keystroke produces over-announcing and is throttled with debounce.

#### Next Step

The measurement entry form now holds its value, applies its rules identically on both sides,
and presents its errors so that everyone can perceive them. One question the form asks
remains: who is entering this measurement? Not everyone can write to North Slope's records;
the observer must be recognized first. The next lesson covers the client side of the identity
flow — where the token issued by the server lives in the browser, and which risks that
decision opens and which it closes.

### Authentication in the Browser

Source: https://academia.sh/en/courses/frontend-architecture/authentication-in-the-browser

The distinction between authentication and authorization, comparing session cookies with bearer tokens, the readability of a token's body, a risk–benefit analysis of the three storage options, and what cookie attributes do.

The measurement entry form now holds its value, applies its rules identically on both sides,
and presents its errors so that everyone can perceive them. One question the form asks
remains: who is entering this measurement? Not everyone can write to North Slope's records;
the observer must be recognized first.

This lesson covers the **client side** of the identity flow: where the proof of identity the
server issues, once it recognizes the observer, lives in the browser, which request it
travels with, and which risks that decision opens and which it closes. The scope is
deliberately narrow — the whole of client-side security, including content security policy
and cross-origin resource sharing, is covered in the Client-Side Security topic of the
Frontend Quality course. Only the identity flow itself is here.

#### Authentication and Authorization Are Separate Layers

**Authentication** is the answer to "who are you." The observer supplies a username and
password, the server verifies them, and produces a proof of identity.

**Authorization** is the answer to "can you do this." A recognized observer might have the
right to write measurements to station NS-01, and not to NS-02.

Keeping the two apart is not wordplay. The authorization decision is **always made on the
server**; what the client does is not show a button the user cannot reach. This visual
filtering is not a security control, it is a usability improvement: hiding the button does not
stop the request from being built some other way.

#### Two Ways to Carry It

There are two ways for the proof of identity to travel with a request.

A **session cookie** carries a random identifier the server generates. The identifier itself
carries no meaning; the server looks it up in its session records and finds the matching
user. The browser sends the cookie **on its own**; the application's code adds nothing to
each request.

A **bearer token** is a signed string that carries the user's information inside itself. JWT
is a common example. The server does not have to look up a record on every request; it
verifies the signature and reads the claims in the body. The token is sent by hand, in a
header; the browser does not do this on its own.

The distinction has two practical consequences. Being sent automatically leaves the session
cookie open to **cross-site request forgery (CSRF)**: a request triggered from another page
carries the cookie along with it too. Being sent by hand keeps the bearer token clear of this
risk, but requires the token to be stored somewhere — and wherever it is stored opens a new
risk. This trade-off is the core of the lesson.

The second consequence of carrying information inside itself is **irrevocability**. Once the
session record is deleted on the server, the session ends at that moment. A signed token, on
the other hand, stays valid until it expires; revoking it requires the server to keep a
separate denylist — which gives back most of the gain from not keeping records. In practice
this is balanced by keeping tokens **short-lived**.

#### A Token's Body Is Not Secret

A token is signed; that means its content **cannot be altered**, not that it is secret. This
difference directly determines what can be put inside a token, and what the client can do
with it.

```js
// token-reading.mjs — how readable a token is on the client
import { createHmac, timingSafeEqual } from "node:crypto";

const b64 = (v) => Buffer.from(typeof v === "string" ? v : JSON.stringify(v))
  .toString("base64url");

// --- Server side: issues and signs the token --------------------------------
const SECRET = "a-key-that-lives-only-on-the-server";
const NOW = 1768348800;                       // 2026-01-14T12:00:00Z, fixed for the example

function issueToken(claims, secret) {
  const body = `${b64({ alg: "HS256", typ: "JWT" })}.${b64(claims)}`;
  const signature = createHmac("sha256", secret).update(body).digest("base64url");
  return `${body}.${signature}`;
}

const token = issueToken({
  sub: "observer-3", role: "writer", station: "NS-01",
  iat: NOW, exp: NOW + 900,                   // 15 minutes
}, SECRET);

console.log("token:", token);

// --- Client side: reads the body without verifying the signature -----------
function readBody(token) {
  const parts = token.split(".");
  if (parts.length !== 3) return null;
  try { return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); }
  catch { return null; }
}

const claims = readBody(token);
console.log("body the client reads:", JSON.stringify(claims));

// Expiry checking is a usability calculation, not an authorization decision.
const ALLOWANCE = 30;                          // clock skew allowance (seconds)
const hasExpired = (c, now) => now >= c.exp - ALLOWANCE;
for (const now of [NOW, NOW + 600, NOW + 880, NOW + 1000]) {
  console.log(
    `t=${String(now - NOW).padStart(4)}s  left=${String(claims.exp - now).padStart(4)}s`,
    ` should renew: ${hasExpired(claims, now) ? "yes" : "no"}`);
}

// --- Server side: signature verification ------------------------------------
function isSignatureValid(token, secret) {
  const [header, payload, signature] = token.split(".");
  const expected = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url");
  const a = Buffer.from(signature), c = Buffer.from(expected);
  return a.length === c.length && timingSafeEqual(a, c);
}

// A token with a tampered body: role changed from "writer" to "admin".
const [header, , oldSignature] = token.split(".");
const tampered = `${header}.${b64({ ...claims, role: "admin" })}.${oldSignature}`;

console.log("\noriginal token, server verification  :", isSignatureValid(token, SECRET));
console.log("tampered body, server verification   :", isSignatureValid(tampered, SECRET));
console.log("tampered body, client reads          :",
  JSON.stringify(readBody(tampered).role));
```

```
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJvYnNlcnZlci0zIiwicm9sZSI6IndyaXRlciIsInN0YXRpb24iOiJOUy0wMSIsImlhdCI6MTc2ODM0ODgwMCwiZXhwIjoxNzY4MzQ5NzAwfQ.HWgZfVP_bRqv1UsSGiRGB5Bi-jMTXDyGc1q9eRbpi78
body the client reads: {"sub":"observer-3","role":"writer","station":"NS-01","iat":1768348800,"exp":1768349700}
t=   0s  left= 900s  should renew: no
t= 600s  left= 300s  should renew: no
t= 880s  left=  20s  should renew: yes
t=1000s  left=-100s  should renew: yes

original token, server verification  : true
tampered body, server verification   : false
tampered body, client reads          : "admin"
```

The output gives three rules.

**The body is text anyone can read.** Encoding is not encryption; anyone holding the token
reads what is inside it. This is why nothing beyond the user's identity and permission names
goes into the body — no personal information, internal system identifiers, or secret values.

**A tampered signature is caught, but only on the server.** The tampered token did not pass
server verification; the signature does not match the body. The client still reads that same
token's body without trouble, and sees the role as "admin." So **the role the client reads is
not a claim in force, only a display**. The interface can draw a menu with this information;
it cannot make an access decision with it.

**Expiry checking is a usability calculation.** The client can look at the `exp` field and
renew the token before it expires, so the user avoids a 401 response. But this check is not
an authorization decision: the client's clock might be wrong, the body might have been
tampered with. The thirty-second allowance in the example is set against clock skew and pulls
the decision earlier. The server still decides.

#### Token Storage: Three Options

Where the token is stored reduces to three options, and each opens a different risk. In the
table, "if a script runs" describes the situation where a script that does not belong to the
application runs on the page — cross-site scripting, XSS.

| Criterion | In-memory variable | Web Storage | `HttpOnly` cookie |
|---|---|---|---|
| Readable if a script runs | Yes (in the same execution context) | Yes, directly | No, unreachable from a script |
| Forgery (CSRF) risk | None, sent by hand | None, sent by hand | Present; narrowed by attributes and a token |
| On page refresh | Lost | Kept | Kept |
| Across tabs | Not shared | Shared in `localStorage`, not in `sessionStorage` | Shared |
| Leakage to subdomains | None | Separate per origin | Happens if `Domain` is written |
| Server-side revocation | Easy if short-lived | Easy if short-lived | Instant with the session record |

No single "correct option" follows from the table; two arrangements are common and
defensible.

**The access token in memory, the refresh token in an `HttpOnly` cookie.** The short-lived
access token is never written to any persistent store; it is lost on page refresh and
reacquired with a refresh request carried by the cookie. If a script runs, the access token
can be read, but its lifetime is limited to minutes; the refresh token can never be reached.

**Everything in an `HttpOnly` cookie, with the session record on the server.** No identity
value can be read from a script, and revocation is instant. In exchange, forgery risk is
taken on and narrowed with attributes.

The arrangement to avoid is **keeping the refresh token in Web Storage**: a long-lived proof
of identity sits where a script can read it. Keeping the short-lived access token there, by
contrast, is a trade-off — readability risk taken on in exchange for surviving a page
refresh.

The storage choice has a limit: **if a script can run at all, the store barely matters.**
Even if the token sits in an `HttpOnly` cookie, a script running on the page can send a
request on the user's behalf. The storage decision narrows the risk; it does not eliminate
it. The real defense is preventing the script from running at all, and that is the subject of
the Frontend Quality course.

#### What Cookie Attributes Do

In arrangements that use cookies, four attributes carry a direct security function, and all
four are declared in the header the server writes.

`HttpOnly` blocks the cookie from being read by a script. Without this attribute, the cookie
becomes just as readable as Web Storage.

`Secure` ensures the cookie is only sent over an encrypted connection. It has no exception
outside local development addresses.

`SameSite` decides whether the cookie is sent on requests triggered from another site.
`Strict` never sends it in any outside context, which makes a user arriving from an external
link look signed out; `Lax` sends it on top-level navigations but not on embedded requests;
`None` sends it in every case and can only be used together with `Secure`. This is the
attribute that actually narrows the forgery risk, but it is not considered sufficient on its
own: state-changing requests are additionally protected with a forgery token.

`Path` and `Domain` determine the paths and domains the cookie will be sent to. Writing
`Domain` opens the cookie to every subdomain; a compromised subdomain then affects the
session too. When left unwritten, the cookie goes only to its own host name, which is usually
what is wanted. The `__Host-` prefix placed on a cookie's name turns
this narrowing into a rule: a cookie with this prefix is only accepted with `Secure`, without
`Domain` written, and with a root path.

#### The Client's Identity State

The answer the application gives to "is a session open" is also a piece of state, and follows
the distinctions from the State Management topic. This state is derived not from the token
itself, but from **whether a token exists and from the server's responses**.

At application startup, the identity state is unknown. In an arrangement that keeps the token
in memory, there is nothing on hand once the page refreshes; the only way to learn whether the
session is still active is to ask the server. This means identity state has a fourth value:
`unknown`. Interfaces written without this value appear signed out for a moment at startup
and throw the user onto the login screen.

Route guarding is also tied to this state. The route guard rule from the Routing topic
manages **visibility** on the client side; a protected route's data is still protected by
getting a 401 from the server. The client's guard can be bypassed; the server's cannot.

#### Summary

- Authentication is the "who are you" question, authorization is "can you do this"; the
  authorization decision is always made on the server, and the client only manages
  visibility.
- A session cookie is sent automatically by the browser and is open to forgery; a bearer
  token is sent by hand and must be stored.
- A token being signed says its content cannot be altered, not that it is secret; the body is
  readable by anyone, and the role the client reads is not proof of authorization.
- Expiry checking on the client is a usability calculation; it is pulled earlier by a clock
  skew allowance, but the server makes the decision.
- The three storage options open different risks; the two defensible arrangements are
  keeping the access token in memory with the refresh token in an `HttpOnly` cookie, or
  keeping everything in a cookie and separately guarding against forgery.
- The `HttpOnly`, `Secure`, `SameSite`, and `Domain` attributes narrow the risk; if a script
  can run on the page, no storage option protects on its own.

#### Next Step

This lesson presented the token's short lifetime as a defense, but the short lifetime has a
cost: the session dropping every fifteen minutes. If the token expires while the observer is
filling out the measurement form, the submission comes back with a 401 and everything they
wrote is put at risk. The next lesson builds a way to keep the short lifetime without paying
that cost: renewal the user does not notice, gathering multiple simultaneously dropped
requests into a single renewal, and the session closing everywhere at the same time.

### Session Renewal

Source: https://academia.sh/en/courses/frontend-architecture/session-renewal

Refreshing a short-lived token without it reaching the user, gathering concurrent authorization errors into a single renewal, the refresh token's rotation and reuse detection, and sign-out taking effect everywhere.

The previous lesson presented the token's short lifetime as a defense. The cost of that short
lifetime is clear: the session dropping every fifteen minutes. If the time runs out while the
observer is filling out the measurement form, the submission comes back with a 401, and
everything they wrote is put at risk.

This lesson builds the arrangement that keeps the short lifetime without paying that cost:
refreshing without the user noticing, gathering multiple simultaneously dropped requests into
a single refresh, protecting the refresh channel itself, and the session closing everywhere
at the same time.

#### Renewal's Three Triggers

**Reactive renewal** happens when a 401 response is received. The request layer sees the
response, refreshes the token, and repeats the request once. It is the simplest and most
resilient path: it relies on the server's decision, not on the client's clock.

**Proactive renewal** happens shortly before the time runs out. The expiry check from the
previous lesson serves this: the token's `exp` field is read, and it is refreshed early,
leaving an allowance. Its payoff is that the user does not wait for a request to go out twice.
It is not sufficient on its own — the client's clock might be wrong, the server might have
revoked the token early — so it does not replace reactive renewal, it works alongside it.

**Silent session establishment at startup** happens when the page refreshes. In an
arrangement that keeps the access token in memory, nothing is left on hand once the page
refreshes; the application tries the refresh channel once at startup. If it succeeds, the
session continues where it left off; if it fails, the session is considered closed. The
`unknown` identity state mentioned in the previous lesson applies for exactly the duration of
this attempt.

#### Single Flight

When a screen opens, several requests usually go out at once: the station list, measurement
history, a summary, alerts. If the token has expired, all four get a 401. If each one starts
its own renewal, four renewal requests go to the server — and if the token rotates, three of
them arrive with an invalidated token, and the session closes.

The solution is the **single flight** pattern: the promise produced when renewal starts is
stored, and everyone requesting a renewal at the same time waits on that same promise.

```js
// session-renewal.mjs — silent renewal, single flight, and reuse detection
import http from "node:http";

// --- Server -------------------------------------------------------------------
let version = 1;
let currentAccess = "e1", currentRefresh = "y1";
let sessionOpen = true, renewalCount = 0;
const used = new Set();

const readBody = (request) => new Promise((resolve) => {
  let m = ""; request.on("data", (p) => (m += p)); request.on("end", () => resolve(m ? JSON.parse(m) : {}));
});

const server = http.createServer(async (request, response) => {
  const path = new URL(request.url, "http://127.0.0.1").pathname;
  const send = (status, body) => {
    response.writeHead(status, { "content-type": "application/json" });
    response.end(JSON.stringify(body));
  };

  if (path === "/measurements") {
    if (!sessionOpen || request.headers.authorization !== `Bearer ${currentAccess}`)
      return send(401, { code: "no_identity" });
    return send(200, { record: "o-114", version });
  }

  if (path === "/session/refresh") {
    const { refresh } = await readBody(request);
    if (used.has(refresh)) {                  // same token twice: revoke the family
      sessionOpen = false;
      return send(401, { code: "reuse_detected" });
    }
    if (!sessionOpen || refresh !== currentRefresh)
      return send(401, { code: "invalid_refresh" });
    used.add(refresh);
    version += 1; renewalCount += 1;
    currentAccess = `e${version}`; currentRefresh = `y${version}`;
    return send(200, { access: currentAccess, refresh: currentRefresh });
  }

  if (path === "/session/close") { sessionOpen = false; return send(204, {}); }
  send(404, { code: "not_found" });
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const BASE = `http://127.0.0.1:${server.address().port}`;

// --- Client ---------------------------------------------------------------------
const session = { access: "e0", refresh: "y1", open: true };   // access deliberately stale
let inFlight = null;

function renew(usedAccess) {
  // Someone else already renewed: this request is retried directly with the new token.
  if (usedAccess !== session.access) return Promise.resolve(true);
  if (inFlight) return inFlight;

  inFlight = (async () => {
    const response = await fetch(`${BASE}/session/refresh`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ refresh: session.refresh }),
    });
    if (!response.ok) { session.open = false; return false; }
    const body = await response.json();
    session.access = body.access;
    session.refresh = body.refresh;           // rotation: the old token is no longer valid
    return true;
  })();
  inFlight.finally(() => { inFlight = null; });
  return inFlight;
}

async function request(name) {
  if (!session.open) return `${name}: session closed`;
  const usedAccess = session.access;
  let response = await fetch(`${BASE}/measurements`,
    { headers: { authorization: `Bearer ${usedAccess}` } });
  if (response.status !== 401) return `${name}: ${response.status}`;

  const renewed = await renew(usedAccess);
  if (!renewed) return `${name}: session dropped`;

  response = await fetch(`${BASE}/measurements`,
    { headers: { authorization: `Bearer ${session.access}` } });
  const body = await response.json();
  return `${name}: ${response.status} version=${body.version ?? "-"}`;
}

// --- 1. Four requests dropping at the same instant ---------------------------
const results = await Promise.all(["station", "history", "summary", "alerts"].map(request));
results.forEach((s) => console.log("  " + s));
console.log("renewal count on the server:", renewalCount);

// --- 2. The old refresh token used a second time -----------------------------
const old = "y1";
const y = await fetch(`${BASE}/session/refresh`, {
  method: "POST", headers: { "content-type": "application/json" },
  body: JSON.stringify({ refresh: old }),
});
console.log("\nold refresh token →", y.status, JSON.stringify(await y.json()));

// --- 3. After the family has been revoked -------------------------------------
session.open = true;                           // the client does not know yet
console.log("request after revocation   →", await request("history"));
console.log("client's session state     :", session.open ? "open" : "closed");

server.close();
```

```
  station: 200 version=2
  history: 200 version=2
  summary: 200 version=2
  alerts: 200 version=2
renewal count on the server: 1

old refresh token → 401 {"code":"reuse_detected"}
request after revocation   → history: session dropped
client's session state     : closed
```

#### Reading the Output

All four requests returned successfully, and the renewal counter on the server showed **one**.
Four separate call sites shared a single renewal; none of them knows the others exist.

Two guards make the gathering work, and both are required. The first is the **in-flight
promise**: a second request arriving while a renewal is underway does not start a new one, it
joins the one in progress. The second is **comparing the token that was used**: a request
that notices the 401 late does not request a renewal at all if one has already finished — it is
retried directly with the new token. With only the first guard in place, a 401 arriving right
after a renewal finishes would start an unnecessary second renewal.

The value `version=2` shows that all four requests were answered with the token from **after**
the renewal. The retry has to read the token that changed during the renewal; using the stale
value captured at the start of the request would have dropped the retry into a 401 too.

#### Rotation and Reuse Detection

The refresh token is long-lived, and that makes it the most valuable part of the session. Two
mechanisms narrow the risk.

**Rotation**: on every renewal, not just the access token changes but the refresh token too.
One that is already been used is never accepted again. So a refresh token's usable lifetime
lasts only until the next renewal.

**Reuse detection**: if a used refresh token arrives a second time, the server does not treat
that as a single client's mistake. There are two possibilities — either a copy is
circulating, or the renewal response never reached the client before the connection dropped.
Since there is no way to distinguish the two, the server picks the safe side and revokes every
token tied to that session. The output's second block shows this: when `y1` was sent a
second time, the response was `reuse_detected`.

The third block shows the consequence of the revocation. The client did not know the session
had closed and sent its request; it got a 401, tried to renew, that was rejected too, and the
client marked the session as closed. So **what tells the client the session has ended is the
renewal failing.**

This mechanism has a side effect: a renewal response missed because of a network drop also
brings the session down. This is an accepted trade-off; the alternative is treating a used
token as valid for a while longer, which reduces the value of the detection.

#### Loop and Queue Traps

Renewal code is exposed to two traps, and both show up in production.

**An infinite loop.** If the renewal request itself returns a 401, and that request also goes
through the same renewal mechanism, the loop never closes. The rule: the renewal request
**never**, under any condition, goes through the renewal mechanism, and is never retried on
its own 401. In the code above, the renewal call does not use the `request` function; it is
sent directly.

**A single-retry limit.** If the request, retried after a renewal, gets a 401 again, a second
renewal is not attempted. This signals that the user really does not have authorization for
that resource; mistaking an authorization error for an identity error and continuing to renew
wears down the session for nothing.

There is also a queueing decision. New requests arriving while a renewal is underway can be
handled two ways: sent with the old token, getting a 401 and joining the queue, or not sent
at all until the renewal finishes. The second produces no unnecessary requests, but requires
the application to route every request through a gate aware of the identity state. The code
above implements the first; all four requests went out, all four got a 401, and all four were
gathered into a single renewal.

#### The Sign-Out Flow

Sign-out consists of three steps, and all three are required.

**Invalidation on the server.** In a cookie-based arrangement, the session record is deleted
and the cookie is rewritten with an empty value and a past expiry date. In a token-based
arrangement, the refresh token is revoked. Cleaning up only the client side is not a
sign-out; a copy of the token would still remain valid.

**Clearing the client's state.** The in-memory token, the identity state, and
**user-specific data in the cache** are cleared. The last of these is frequently skipped: if
the measurement history stays in the cache, a second observer signing in on the same browser
sees the previous user's data for a moment. The normalized store from the Data Access topic
is the target of this cleanup.

**Cross-tab synchronization.** The same user might have the application open in three tabs.
If a sign-out in one tab is not announced to the others, those tabs keep behaving as if a
session were open and get an unexpected 401 on their first request. The announcement is made
either with message passing between tabs of the same origin, or by watching for a change in
shared storage; both were introduced in the Browser and the Web Platform course. The same
mechanism works in reverse too: a session opened in one tab is treated as open in the others.

Sign-out's counterpart on the user's side also needs consideration. An unfinished form's
content is discarded on sign-out; asking the user about this is better than discarding it
silently. On a forced sign-out — when the session drops — the address the user was on is
saved while they are redirected to the login screen, and they are returned there after signing
in.

#### Summary

- Renewal has three triggers: reactive after a 401, proactive before the time runs out, and
  silent session establishment at page startup. Proactive renewal does not replace reactive
  renewal.
- Concurrent authorization errors are gathered into a single-flight renewal; the in-flight
  promise and comparing the token that was used are both required together.
- A retried request must read the token that changed during the renewal; the value captured
  at the start of the request is no longer valid.
- The refresh token rotates on every use; a used token arriving a second time results in the
  whole session being revoked.
- The renewal request never goes through the renewal mechanism, and a retried request that
  gets a second 401 is not renewed again.
- Sign-out requires all three steps together: invalidation on the server, clearing the
  client's state and user-specific cache, and cross-tab synchronization.

#### Next Step

The identity flow is complete: the observer is recognized, their session carries on by
itself, and sign-out takes effect everywhere. But the recognized observer also has a
language. Every piece of text produced throughout this course — error messages, the "session
dropped" notice, empty-result explanations, date and number formats — was written for a
single language. For an observer watching the station from Norway, "−4.2" is not the right
format, "2 fields need correction" is not the right sentence, and even the interface's flow
direction might differ. The final lesson builds that adaptation.

### Internationalization

Source: https://academia.sh/en/courses/frontend-architecture/internationalization

Separating text from code, translation catalog and key design, plural rules changing by language, leaving formatting to the platform, direction support, and selecting the locale.

The identity flow is complete: the observer is recognized, and their session carries on by
itself. But the recognized observer also has a language. Every piece of text produced
throughout this course was written for a single language — error messages, the "session
dropped" notice, empty-result explanations. Numbers and dates were the same way: the
measurement value was written as "−4.2", the date was shown in day-month-year order.

None of this is correct for an observer watching the North Slope station from another
country. This lesson builds the structure that makes the interface adaptable to language and
region.

#### Two Separate Jobs

**Internationalization** makes the application adaptable: separating text from code, not
hardcoding formatting, and having the layout hold up under a direction change. It is done
once, and it is independent of language.

**Localization** is producing content for a specific language and region: writing the
translations, choosing dates and currency to fit that region. It is done separately for each
language.

The practical value of this distinction follows directly. Adding a translation to an
application that has not been internationalized requires touching every part of the code.
Adding a second language to one that has means writing nothing more than a catalog file. This
is why separating text from code is a cheap investment even in applications expected to stay
single-language.

#### Translation Catalog and Key Design

A **translation catalog** is a structure that maps keys to messages. The code knows the key,
not the text.

The key itself is a design decision. Using the source sentence as the key — "2 fields need
correction" — looks easy, but every translation breaks whenever that text is edited. A
structured name that defines the meaning — `form.summary` — protects that link. Because the
same word may need to be translated differently in two places, the key also carries context:
`button.save` and `menu.save` are separate keys, even though both are "Save" in English.

```js
// translation-catalog.mjs — keys, placeholders, and plural rules
const CATALOG = {
  tr: {
    "form.summary": { one: "{count} alan düzeltilmeli", other: "{count} alan düzeltilmeli" },
    "measurement.range": "{min} ile {max} arasında bir değer girin.",
    "session.expired": "Oturumunuz sona erdi.",
  },
  en: {
    "form.summary": { one: "{count} field needs attention", other: "{count} fields need attention" },
    "measurement.range": "Enter a value between {min} and {max}.",
  },
};

const DEFAULT = "tr";

function message(locale, key, vars = {}) {
  const entry = CATALOG[locale]?.[key] ?? CATALOG[DEFAULT]?.[key];
  if (entry === undefined) return `!${key}`;   // a missing key is not hidden
  const pattern = typeof entry === "string"
    ? entry
    : entry[new Intl.PluralRules(locale).select(vars.count)] ?? entry.other;
  return pattern.replace(/\{(\w+)\}/g, (whole, name) =>
    name in vars ? String(vars[name]) : whole);
}

console.log("plural categories (Intl.PluralRules)");
console.log("count".padEnd(6) + ["tr", "en", "ru"].map((d) => d.padEnd(8)).join(""));
for (const count of [0, 1, 2, 5, 11, 21, 100]) {
  console.log(String(count).padEnd(6) +
    ["tr", "en", "ru"].map((d) => new Intl.PluralRules(d).select(count).padEnd(8)).join(""));
}

console.log("\nmessages");
for (const locale of ["tr", "en"]) {
  for (const count of [1, 2]) {
    console.log(`  ${locale} count=${count}: ${message(locale, "form.summary", { count })}`);
  }
  console.log(`  ${locale} range  : ${message(locale, "measurement.range", { min: -60, max: 60 })}`);
  console.log(`  ${locale} session: ${message(locale, "session.expired")}`);
  console.log(`  ${locale} missing: ${message(locale, "measurement.unknown")}`);
}
```

```
plural categories (Intl.PluralRules)
count tr      en      ru      
0     other   other   many    
1     one     one     one     
2     other   other   few     
5     other   other   many    
11    other   other   many    
21    other   other   one     
100   other   other   many    

messages
  tr count=1: 1 alan düzeltilmeli
  tr count=2: 2 alan düzeltilmeli
  tr range  : -60 ile 60 arasında bir değer girin.
  tr session: Oturumunuz sona erdi.
  tr missing: !measurement.unknown
  en count=1: 1 field needs attention
  en count=2: 2 fields need attention
  en range  : Enter a value between -60 and 60.
  en session: Oturumunuz sona erdi.
  en missing: !measurement.unknown
```

The plural table shows why a hand-written plural rule is not enough. Turkish and English have
two categories, and the test `count === 1` gives the right answer in both. Three of Russian's
four categories show up in this table, and the choice depends not just on the number itself
but on its last digit: 21 falls into the "one" category, while 11 stays in "many." Code that
writes the rule by hand gets rewritten the moment a third language is added.
`Intl.PluralRules` reads the rule from the language; the catalog only says what to write for
each category.

Turkish's two forms carrying the same text is not an inconsistency. The catalog preserves the
**structure**; how many forms a given language uses is that language's own decision. When the
same catalog gets Russian added to it, `few` and `many` keys are written too, and the code
does not change.

Two more behaviors show up in the output. **A missing key is not hidden**: a display marked
with an exclamation point is produced for a key that does not exist. Returning an empty
string, or silently skipping the key, would make the missing translation invisible, and it
would only be noticed after release. **A missing translation falls back to the default
language**: the session message, absent from the English catalog, came back in Turkish. This
is better than the missing text not appearing at all, but it is a warning sign; it should be
logged during development.

One last rule: **messages are not built with string concatenation**. Text written as "Total "
+ count + " records" cannot be translated into a language with a different word order. The
parts stay together in a single pattern, with placeholders.

#### Leaving Formatting to the Platform

Everything besides text — number, date, duration, list, sorting — changes by language and
region, and every one of these rules is already available on the platform.

```js
// formatting.mjs — how numbers, dates, lists, and sorting depend on locale
const LOCALES = ["tr", "en-US", "de-DE"];
const MEASUREMENT = -4.2;
const INSTANT = new Date("2026-01-14T06:00:00Z");     // fixed instant: output is reproducible

console.log("temperature (with unit)");
for (const l of LOCALES) {
  console.log("  " + l.padEnd(6) +
    new Intl.NumberFormat(l, { style: "unit", unit: "celsius",
      minimumFractionDigits: 1 }).format(MEASUREMENT));
}

console.log("large number");
for (const l of LOCALES) {
  console.log("  " + l.padEnd(6) + new Intl.NumberFormat(l).format(1840.5));
}

console.log("date and time (UTC)");
for (const l of LOCALES) {
  console.log("  " + l.padEnd(6) + new Intl.DateTimeFormat(l, {
    dateStyle: "long", timeStyle: "short", timeZone: "UTC" }).format(INSTANT));
}

console.log("relative time (-3 hours)");
for (const l of LOCALES) {
  console.log("  " + l.padEnd(6) +
    new Intl.RelativeTimeFormat(l, { numeric: "auto" }).format(-3, "hour"));
}

console.log("list");
const stations = ["North Slope", "East Ridge", "West Valley"];
for (const l of LOCALES) {
  console.log("  " + l.padEnd(6) +
    new Intl.ListFormat(l, { style: "long", type: "conjunction" }).format(stations));
}

// --- Two Turkish-specific traps ---------------------------------------------
const CITIES = ["Ordu", "Ödemiş", "Osmaniye", "Sivas", "Şile", "Isparta", "İstanbul"];
console.log("\nsorting");
console.log("  tr    " + [...CITIES].sort(new Intl.Collator("tr").compare).join(", "));
console.log("  en    " + [...CITIES].sort(new Intl.Collator("en").compare).join(", "));
console.log("  raw   " + [...CITIES].sort().join(", "));

console.log("uppercasing");
console.log("  tr    " + "ilk ölçüm".toLocaleUpperCase("tr"));
console.log("  en    " + "ilk ölçüm".toLocaleUpperCase("en"));
```

```
temperature (with unit)
  tr    -4,2°C
  en-US -4.2°C
  de-DE -4,2 °C
large number
  tr    1.840,5
  en-US 1,840.5
  de-DE 1.840,5
date and time (UTC)
  tr    14 Ocak 2026 06:00
  en-US January 14, 2026 at 6:00 AM
  de-DE 14. Januar 2026 um 06:00
relative time (-3 hours)
  tr    3 saat önce
  en-US 3 hours ago
  de-DE vor 3 Stunden
list
  tr    North Slope, East Ridge ve West Valley
  en-US North Slope, East Ridge, and West Valley
  de-DE North Slope, East Ridge und West Valley

sorting
  tr    Isparta, İstanbul, Ordu, Osmaniye, Ödemiş, Sivas, Şile
  en    Isparta, İstanbul, Ödemiş, Ordu, Osmaniye, Şile, Sivas
  raw   Isparta, Ordu, Osmaniye, Sivas, Ödemiş, İstanbul, Şile
uppercasing
  tr    İLK ÖLÇÜM
  en    ILK ÖLÇÜM
```

This output **depends on the environment**: the details of the formats can vary with the
version of locale data the runtime carries. The lines above were produced on an installation
where `node --version` reports v24.18.0. What can change is the detail of the format; the
principles do not.

Five observations follow.

**The decimal separator and the thousands separator swap places by language.** Turkish and
German write the decimal with a comma, English with a period. Code that formats a number by
hand misses this, and would show "1.840,5" as "1840.5"; worse, it would misread a "1,5" a
user typed. This is why the validation schema converted between comma and period.

**Even whether the unit sticks to the number depends on the language.** In the German output
there is a space between the number and the degree sign; in Turkish there is not.

**Date order and the month name change; so does the time format.** The English output uses a
twelve-hour display, the others a twenty-four-hour one. The time zone has to be supplied
separately; in the example, UTC is fixed, so the output is reproducible.

**Joining a list is grammar.** Turkish puts "ve" before the last item, English a comma and
"and," German "und." Placing commas by hand and adding a conjunction before the last item
means a separate rule for every language.

**Sorting and case conversion are a special case in Turkish.** Raw sorting gives code-point
order and does not match the Turkish alphabet at all. In Turkish sorting, `O` and `Ö`, `S` and
`Ş` are separate letters and are arranged in that order; in English sorting they are counted
as variants of the same letter, so "Ödemiş" sits in a different place in the two lists. In
case conversion, too, the uppercase of `i` is `İ` in Turkish and `I` in other languages; a
conversion done without supplying the locale produces a result that varies with the machine
it runs on.

#### Direction Support

Right-to-left languages require not just the text but the layout to change direction too.
The work splits into three levels.

**The document level.** Direction is declared on the root element; direction is a property of
the writing system, not of the language, so it is supplied separately from the language
declaration. When direction changes, text alignment, which side a list's bullet sits on, and
scroll direction all flip on their own.

**The layout level.** The logical properties introduced in the Visual Presentation with CSS
course find their match here: when the start and end edges of the block and inline axes are
written instead of left and right, the layout adapts to a direction change on its own. Every
rule written with a physical direction has to be reviewed one by one when direction support
is added.

**The text level.** Mixed-direction text — a left-to-right station code inside a
right-to-left sentence — raises a special problem: the ordering algorithm can push
punctuation at the boundary to the wrong side. A fragment coming from the user or from data
needs to be isolated from the surrounding text; markup has an element set aside for this, and
this element's default direction guess looks at the fragment's own content.

Some icons are directional too: a forward arrow, a back arrow, indentation markers get
mirrored on a direction change. Icons with no inherent direction, like a clock face or a
volume level, are not mirrored.

#### Selecting the Locale and Loading the Catalog

Which language to use comes from three sources, and the priority order is fixed. **The user's
explicit preference** sits at the top, and is stored. Failing that, **the address itself** is
used — a path segment or subdomain carrying the language code — which keeps the link
shareable. If neither is present, the language list the browser reports, the
`Accept-Language` header, is evaluated.

The browser's list is a ranked preference, not a single language. Matching looks for an exact
match first; if none is found, the region is dropped and tried again; if nothing sticks, it
falls back to the default language. A locale contains more than a language — region,
calendar, numbering system — so there are cases where the text language and the formatting
setting can differ.

Loading the catalog is a bundling decision. Shipping every language in a single bundle makes
the user download text they will never use. The right approach is producing a separate chunk
per language and loading only the selected one; this ties directly to the Code Splitting
topic in the Rendering Strategies and Infrastructure course. What the interface shows during
the time it takes to load a new catalog after a language switch — the old language, or a
blank — connects to the state machine from the Loading and Error States lesson.

Finally, translated text **takes up space**. The same sentence can be noticeably longer in
one language than another; fixed-width buttons and labels squeezed onto a single line
overflow once translated. Having the layout hold up under text length is an
internationalization requirement.

#### Summary

- Internationalization makes the application adaptable and is done once; localization is
  content produced separately for each language.
- A translation catalog uses structured keys that define meaning; making the source sentence
  the key breaks every translation once the text is edited.
- Plural categories change by language and can depend on a number's last digit; the rule is
  read from the platform, and the catalog only carries what to write for each category.
- Messages are built with a single pattern and placeholders, not string concatenation; a
  missing key is not hidden, and a missing translation falls back to the default language.
- Number, date, relative time, list, and sorting are left to the platform's locale tools;
  sorting in Turkish and converting the letter `i` do not produce a correct result without
  supplying the locale.
- Direction support is handled separately at the document, layout, and text levels; logical
  properties resolve the layout, and an isolation element resolves mixed-direction text.

#### Course Wrap-Up

This course built an application's internal architecture around four questions.

**Where is the user?** The Routing topic made the address a part of the application's state:
nested layouts shared a common shell, route parameters carried state in the address, route
guards managed access at the visibility level, and code splitting made every route pay its
own cost.

**Where does information live?** The State Management topic split state into its kinds —
local, shared, server, and address state — and showed a different container for each.
Treating server state separately opened the door to the next topic.

**How does information arrive?** The Data Access topic built the request layer and the error
contract, showed cache normalization for query-based access, consumed streamed data in a way
that resists disconnection, reduced a view's states to a finite set, and absorbed network
instability without it reaching the user.

**How does information get entered, and who enters it?** The Forms and Identity topic built
where a value is held, how rules stay identical on both sides, how an error is made
perceivable, where a proof of identity is stored, how a session keeps going, and how the
interface adapts to language.

What all four have in common is that they all take place **inside the browser**. This course has
covered what happens after the application opens; we never once asked what happens before it
opens. Yet the same application can be built in three different places: in the user's
browser, on the server answering the request, or at build time, before any request arrives.
The choice determines how quickly the first screen arrives, what the search engine sees, how
much work the server does, and how fresh the content can be.

The next course — **Rendering Strategies and Infrastructure** — builds this choice along with
its trade-offs: the costs of rendering on the client, on the server, and at build time,
selectively refreshing static output, gaining interactivity piece by piece, bundling and
asset optimization, and the deployment pipeline that gets the application to the user.

