Why one write touches one text node
Each state and computed is a node in a graph, each text or attribute that reads it is a subscription, and a write runs only the subscriptions on its path.
Last page, the click handler wrote count, and one number on the page changed. Who decided which number?
The compiler decided, before the page existed. It wrote down who reads what.
The problem
A write must change the page. One common way is to run the component again and compare the result with the page. That costs the same on every write, even when one character changes.
Markless skips that work. It needs a list that says which text, attributes, and rows read each piece of state.
The mental model
Markless keeps that list in the state graph. Here are its three parts:
- A node holds one value. Each
state()becomes a node with an ID likestate:count. Eachcomputed()becomes one too. - A subscription is a reader. It names one node and one path into that node’s value. It runs a small function when that path changes.
- The journal is the list of DOM operations that the subscriptions return, such as “set this text”.
Think of plumbing. That is an analogy. A write opens one tap. Water reaches only the pipes on that tap.
Press Add one a few times. Then press Rename.
Which readers wake when one state changes?
Press Add one. Then press Rename. Only the nodes on the written path light up.
Apples
Count: 2
Doubled: 4
import { computed, state } from '@markless/core'; export function Basket() @{ let count = state(2); let label = state('Apples'); const doubled = computed(() => count * 2); <section> <h2>{label}</h2> <p>Count: {count}</p> <p>Doubled: {doubled}</p> <button onClick={() => count++}>Add one</button> <button onClick={() => (label = label === 'Apples' ? 'Pears' : 'Apples')}> Rename </button> </section>}state:countasleep
countasleep
asleep
computed:doubled, depends on countasleep
doubledasleep
asleep
state:labelasleep
labelasleep
asleep
- noteReady. Press a button in the page.
Simplified. Node IDs follow the compiler (state:count, computed:doubled). Journal entry names come from DomJournalEntry in packages/runtime/src/graph.ts, applied to the DOM by packages/web/src/dom-journal.ts.
Notice that Add one wakes two subscriptions and Rename wakes one. The component never runs again. A write runs subscriptions, not component code.
One write, step by step
Here is a counter with one computed value. It comes from Markless’s codegen size corpus:
import { computed, state } from '@markless/core';
export function StateComputed() @{
let count = state(2);
const doubled = computed(() => count * 2);
<section>
<button onClick={() => count++}>Next</button>
<output>{count}</output>
<output>{doubled}</output>
</section>
}
When the click handler runs count++, this happens:
- If the new value equals the old value, nothing happens.
- Otherwise the graph stores it and records the written path as dirty.
- The graph also marks
doubledas stale, becausedoubleddepends oncount. - The graph schedules one flush as a microtask. A microtask runs right after the current code finishes.
- The flush runs each subscription whose path meets a dirty path. The
doubledsubscription readsdoubled, so it recomputes now. - Each subscription returns a journal entry, such as
setText. Markless applies the entries to the DOM.
Two writes in the same turn share one flush. If a handler writes 1 and then 2, the journal gets one entry with 2.
Misconception: something compares the old page with the new page. Nothing does. A subscription returns the exact operation. There is no tree to compare.
Paths, not whole objects
Subscriptions listen to a path inside a value, not to the whole value. Two paths meet when one is a prefix of the other.
const menu = state({ open: true, title: 'Menu' });
<h2>{menu.title}</h2>
<button onClick={() => { menu.open = false; }}>Close</button>
A write to menu.open does not wake the reader of menu.title. A write that replaces menu wakes both, because menu is a prefix of both paths.
Computed values
A computed node lists the paths it depends on. A write to one of those paths marks it stale. It recomputes on its next read, not before. Chains work the same way: a computed that reads another computed goes stale with it.
On a server-rendered page, the markless/state script carries each computed as a recipe. The recipe is its dependencies plus the ID of a derive symbol. The current value travels too, but only for a computed that a handler reads.
What a subscription can return
Every DOM change goes through the journal. These are the entry types in @markless/runtime:
| Entry | What it does |
|---|---|
setText |
Sets the text of one text node |
setAttr, setProp |
Sets one attribute or one DOM property |
insertRange, removeRange, moveRange |
Inserts, removes, or moves a range of nodes, such as an @if arm |
runCleanup |
Runs a cleanup function registered for one locator |
Misconception: you need an effect to keep the page in sync. @markless/core exports no effect function. Its authoring exports are state, computed, shared, element, and storage. The compiler plans every subscription for you.
Next: Each subscription and each handler is a small function. When does that code download, and when does it run? Lazy chunks →
