Lesson 10 / 20
Local Storage
Local storage keeps the data that must remain even when the app closes: a key-value store for small values, an embedded database for records that are queried. The lesson measures that a draft read from storage at launch is mounted in the tree, and that writing to storage does not change the tree.
Published Updated
In this lesson
- Explains the choice between a key-value store and an embedded database according to the shape of the data
- Measures that a draft read from storage at launch comes out mounted in the view tree
- Shows that when the store is empty, launch changes no node
- Measures that writing to storage produces no command in the view tree
Contents
The previous lesson covered data from the server and the cache in memory. Memory is emptied when the app closes. If a draft the user typed, a setting the user chose, or a downloaded list should still be there when the app is opened again, the data is written to local storage. This lesson covers the two kinds of storage and how data gets from the store into the tree.
The question of the course does not change: what does this update do to the node in the view tree? The fate is kept, updated, mounted, or unmounted; the total of the updated, mounted, and unmounted nodes is the commands. We do not assign a fate by hand; the rule gives it:
// fate.mjs — classifies what an update does to each node of the view tree export const FATES = ["kept", "updated", "mounted", "unmounted"]; export function el(type, props = {}, children = [], key = null) { return { type, props, children, key }; } function segment(node) { return node.key === null ? node.type : `${node.type}[${node.key}]`; } function identity(node, index) { return node.key === null ? `#${index}` : `key:${node.key}`; } function sameProps(a, b) { const names = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort(); return names.every((n) => JSON.stringify(a[n]) === JSON.stringify(b[n])); } function whole(node, path, fate, ops) { const here = `${path}/${segment(node)}`; ops.push({ fate, path: here }); node.children.forEach((child) => whole(child, here, fate, ops)); } export function reconcile(prev, next, path = "", ops = []) { if (prev === null) { whole(next, path, "mounted", ops); return ops; } if (next === null) { whole(prev, path, "unmounted", ops); return ops; } if (prev.type !== next.type || prev.key !== next.key) { whole(prev, path, "unmounted", ops); whole(next, path, "mounted", ops); return ops; } const here = `${path}/${segment(next)}`; ops.push({ fate: sameProps(prev.props, next.props) ? "kept" : "updated", path: here }); const before = new Map(prev.children.map((child, i) => [identity(child, i), child])); const matched = new Set(); next.children.forEach((child, i) => { const id = identity(child, i); matched.add(id); reconcile(before.has(id) ? before.get(id) : null, child, here, ops); }); prev.children.forEach((child, i) => { if (!matched.has(identity(child, i))) reconcile(child, null, here, ops); }); return ops; } export function printOps(ops) { ops.forEach((op) => console.log(`${op.fate}: ${op.path}`)); } export function countFates(ops) { for (const name of FATES) { console.log(`${name}: ${ops.filter((op) => op.fate === name).length}`); } console.log(`commands: ${ops.filter((op) => op.fate !== "kept").length}`); }
The model builds this fate’s rule; the model is not the framework itself. The rule compares trees; it does not see where the data is stored.
The Key-Value Store
A key-value store binds a name to a value: the name draft to a piece of text, the name theme to a choice. Reading and writing are a single step, and whoever knows the name gets the value. It suits small, independent data that does not need to be queried: settings, the last screen that was opened, a half-finished text. Access to the store is asynchronous so that it does not hold up the app’s main flow: a read returns a promise.
An asynchronous read means that the value is not yet at hand when the screen is first rendered. The screen opens first without the value, the state changes when the store responds, and the screen is rendered again.
// Platform code: DraftEditor
function DraftEditor() {
const [draft, setDraft] = useState(null);
useEffect(() => {
storage.getItem("draft").then(setDraft);
}, []);
return <Editor draft={draft} />;
}
This fragment is not measured; the model does not run the store or the framework.
AL1. The model builds the store as an in-memory Map and returns reads and writes with a Promise. The app closing and opening again does not enter the model; the model measures only what happens in the tree when the read finishes.
// store.mjs — key-value store: reads and writes with a Promise over an in-memory Map const data = new Map(); export function setItem(key, value) { return Promise.resolve().then(() => { data.set(key, value); }); } export function getItem(key) { return Promise.resolve().then(() => (data.has(key) ? data.get(key) : null)); }
AL2. The model counts the draft as not yet read when the screen is first rendered; the tree is built first without the draft. If there is a draft, the Draft node enters the tree under the screen; if there is not, it never enters. renderEditor(state) builds the tree from this state.
// editor.mjs — builds the draft screen's view tree from the state import { el } from "./fate.mjs"; export function renderEditor(state) { const children = [el("Title", { text: "Draft" })]; if (state.draft !== null) children.push(el("Draft", { text: state.draft })); return el("Editor", {}, children); }
The app opens. There is a draft saved earlier in the store. The screen is first built without the draft, and when the read finishes, the draft state enters the tree.
// main.mjs import { reconcile, printOps, countFates } from "./fate.mjs"; import { renderEditor } from "./editor.mjs"; import { setItem, getItem } from "./store.mjs"; const opening = renderEditor({ draft: null }); setItem("draft", "Hello") .then(() => getItem("draft")) .then((saved) => { const ops = reconcile(opening, renderEditor({ draft: saved })); printOps(ops); countFates(ops); });
kept: /Editor kept: /Editor/Title mounted: /Editor/Draft kept: 2 updated: 0 mounted: 1 unmounted: 0 commands: 1
/Editor/Draft is mounted. The title and the screen itself are kept. The command count is 1, and that command is the data read from the store. The store is outside the tree: data enters the tree only through state, and the tree knows it only as a node.
If the store is empty, the state does not change. The read returns null, and the tree is the same as the tree at launch.
// main.mjs import { reconcile, countFates } from "./fate.mjs"; import { renderEditor } from "./editor.mjs"; import { getItem } from "./store.mjs"; const opening = renderEditor({ draft: null }); getItem("missing").then((saved) => { console.log("empty store:"); countFates(reconcile(opening, renderEditor({ draft: saved }))); });
empty store: kept: 2 updated: 0 mounted: 0 unmounted: 0 commands: 0
When the store is empty, the read produces no command. A read having finished is not enough for the tree to change; what changes the tree is the value that was read changing the state.
The Embedded Database
If the data is not a single value but many records, and the records will be queried, a key-value store is not enough. An embedded database runs inside the app and keeps records in tables. Records are looked up by their id, filtered by a condition, and sorted. A list of drafts, pinned drafts, and records sorted by date are examples.
AL3. The model builds the database as a fixed table of rows with ids and limits the query to a single filter: query(onlyPinned) returns all the rows or only the pinned ones.
// db.mjs — embedded database: queries in-memory rows with a Promise const ROWS = [ { id: "t1", title: "Intro", pinned: true }, { id: "t2", title: "Result", pinned: false }, { id: "t3", title: "Appendix", pinned: true }, ]; export function query(onlyPinned) { return Promise.resolve(ROWS.filter((row) => !onlyPinned || row.pinned)); }
The query result enters the screen as a list. The rows are keyed by id; this way, when a filter changes, which rows remain is known from the id.
// main.mjs import { el, reconcile, printOps, countFates } from "./fate.mjs"; import { query } from "./db.mjs"; function renderDrafts(rows) { return el("List", {}, rows.map((row) => el("Item", { title: row.title }, [], row.id))); } Promise.all([query(false), query(true)]).then(([all, pinned]) => { const ops = reconcile(renderDrafts(all), renderDrafts(pinned)); printOps(ops); countFates(ops); });
kept: /List kept: /List/Item[t1] kept: /List/Item[t3] unmounted: /List/Item[t2] kept: 3 updated: 0 mounted: 0 unmounted: 1 commands: 1
The filter removed the row that is not pinned. Item[t2] is unmounted, and the remaining two rows and the list are kept. Because the rows are keyed by id, the t3 row was not counted as a different row even though its position shifted. This is a result independent of the kind of store: the embedded database gives the tree only rows, and what the rule sees is the row.
Reading the Source Fragment Line by Line
The DraftEditor fragment shows in three parts how the store connects to the tree. The first line starts the state with null: when the screen is first rendered, the draft does not exist yet. This is the framework counterpart of the model’s first assumption above. The second part is the side effect that runs once at launch: when the promise read from the store finishes, the result is written as state. The third part is the render: the screen shows whatever the state is.
In the fragment, the only path from the store to the tree is state. The store does not add a node directly to the tree; the read finishes, the state changes, and the screen is rebuilt from that state. The model’s renderEditor(state) function corresponds to this last step. The only difference between them is this: the model puts the end of the read in order with a promise chain, while the framework catches the same end with a side effect. The fate seen in the tree is the same on both paths, because the rule compares trees and does not look at how they were reached.
One consequence of this reading is the following: in the first render the user sees, for a brief moment, a screen without the draft. If the store is fast, this moment goes unnoticed; if it is slow, the screen opens empty first and the draft appears later. This has a counterpart in the tree: the Draft node is mounted not in the first build but in the second comparison, and our measurement compares exactly these two steps.
Deleting the Draft
The opposite of reading and writing is deleting. When the user throws the draft away, the state becomes null again and the Draft node must leave the tree. This is measured too.
// main.mjs import { reconcile, printOps, countFates } from "./fate.mjs"; import { renderEditor } from "./editor.mjs"; const ops = reconcile( renderEditor({ draft: "Hello" }), renderEditor({ draft: null }), ); printOps(ops.filter((op) => op.fate !== "kept")); countFates(ops);
unmounted: /Editor/Draft kept: 2 updated: 0 mounted: 0 unmounted: 1 commands: 1
The reverse of launch happened: the node that was mounted became unmounted. The title and the screen are kept. The deletion produced one command; but this command is the tree’s command, and deleting from the store is a separate job. Removing the draft from the tree does not delete the value in the store. When the app is opened again, the old draft in the store is read again and Draft is mounted again. For the draft the user threw away not to come back, both the state and the store must be cleared; the tree sees only the first.
Which Store for What
The two kinds of store give the same fate in the tree but are chosen for different jobs. A key-value store carries a single value whose name is known, and its read and write steps are simple. It is enough for small data that needs no query. An embedded database gives the data a structure: rows, ids, filters. If the data itself is a collection and the user filters and sorts it, a database is the right choice.
The choice can go wrong in two directions. Serializing a collection into a single key and rewriting it from the beginning on every change causes every small change to read and write all of the data. Keeping a single setting value in a database builds an unnecessary structure. The tree does not see this difference, but the way the screen reacts is affected by this choice: because a database gives rows with ids, the list tree can be keyed, while getting keys out of a single serialized value takes extra work.
Common Misconceptions
- “When something is written to the store, the screen changes.” Writing changes no node in the tree. What changes the screen is the state changing; a save leaves a copy of the state outside the tree.
- “Reading from the store fills the screen at once.” A read is asynchronous. The screen is first built without the value and is updated by a second comparison when the read finishes; this gap can be seen, even if only for a moment.
- “An empty store is an error.” An empty store ends the read with
nulland the state does not change. The tree stays the same as the tree at launch, and commands is 0. - “Data that has left the tree has left the store too.” An unmounted node does not delete the value in the store. The data the user threw away does not come back at the next launch only if it is deleted from the store as well.
- “A database is always better than a key-value store.” Structure is a price. Building a table for a single setting and thinking about row ids brings unnecessary work; the choice looks at the shape of the data, not at the power of the store.
- “The native view of an unmounted node stays in the store.” A native view such as a scroll position goes away with the tree; what stays in the store is only the data the app explicitly wrote. If what the user should see on returning was not written, it is lost.
- “The store needs to be read at every launch.” A read is needed only for the data the screen needs. Reading at launch a value the screen never shows does needless work; the tree does not see this work, but the app’s launch process carries it.
- “If saving takes time, the screen should wait.” Writing is asynchronous and the tree does not wait for the write to finish. The user sees the text they typed instantly; the completion of the save is tracked separately.
- “The data in the store and the data on the screen are always the same.” The two sit in different places and may differ until the write finishes. While the user types, the screen is ahead and the store is behind; the tree shows only the state of the screen.
The Bounding Measurement
Writing to the store does not change the tree. When the user edits the draft, the state changes and Draft is updated in the tree. Writing the draft to the store is a separate job, and when the write finishes nothing new appears in the tree. The measurement below separates the two steps: first the user types, then the text is saved to the store.
// main.mjs import { reconcile, printOps, countFates } from "./fate.mjs"; import { renderEditor } from "./editor.mjs"; import { setItem } from "./store.mjs"; const opened = renderEditor({ draft: "Hello" }); const typed = renderEditor({ draft: "Hello world" }); console.log("typing:"); printOps(reconcile(opened, typed).filter((op) => op.path === "/Editor/Draft")); setItem("draft", "Hello world").then(() => { console.log("save:"); countFates(reconcile(typed, renderEditor({ draft: "Hello world" }))); });
typing: updated: /Editor/Draft save: kept: 3 updated: 0 mounted: 0 unmounted: 0 commands: 0
Typing updated the Draft node, because the text prop changed. Saving to the store, however, changed no node: commands is 0 and the Draft node is kept. The store is a write outside the tree; the rule does not see it and does not need to. Even if the save failed, the tree would stay the same. For this reason, whether a save was done correctly is not understood by looking at the tree; it is understood by reading back from the store.
Summary
- This lesson’s fate: mounted. When the draft read from the store at launch entered the tree, the
/Editor/Draftnode came out mounted. - When the store is empty, the read produces no command: the tree does not change unless the read changes the state.
- In an embedded database the query result is a list; when the filter changes, the row that leaves is unmounted and the remaining rows are kept because they are matched by id.
- Writing to storage produces no command in the view tree; editing updates the
Draftnode, while saving changes no node. - The rule does not see the kind of the store or the success of the write; it only compares trees.
Next Step
In this lesson the data written to the store was ordinary text such as a draft. Some values are not ordinary: a session token, a password, a key. These should not sit in a plain store. The next lesson, Secure Storage, measures where secret values are kept and what happens in the tree when the session closes.
To keep your progress and take notes, Log in
My notes
Log in to take notes.