Skip to content
academia.sh

Lesson 11 / 22

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.

Contents

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.

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close