---
title: 'Persistent State'
source: 'https://academia.sh/en/courses/frontend-architecture/persistent-state'
course: 'Application Architecture: Routing, State and Data'
language: en
updated: '2026-08-17T18:11:05+00:00'
license: 'CC BY-SA 4.0'
---

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