Skip to content
These are temporary community docs. The official docs are in progress.Help improve them
Markless
Esc
↑↓navigate↵open⌘Jpreview
On this page

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 like state:count. Each computed() 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.

The pageTry it
Basket

Apples

Count: 2

Doubled: 4

Your code: Basket.tsrx
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>}
What Markless did: the state graph
Nodestate:count
asleep
Subscriptionreads count
asleep
DOM spotCount text
asleep
Nodecomputed:doubled, depends on count
asleep
Subscriptionreads doubled
asleep
DOM spotDoubled text
asleep
Nodestate:label
asleep
Subscriptionreads label
asleep
DOM spotHeading text
asleep
Subscriptions that ran on the last write0
Times the component ran1At setup. Writes never run it.
  1. 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:

  1. If the new value equals the old value, nothing happens.
  2. Otherwise the graph stores it and records the written path as dirty.
  3. The graph also marks doubled as stale, because doubled depends on count.
  4. The graph schedules one flush as a microtask. A microtask runs right after the current code finishes.
  5. The flush runs each subscription whose path meets a dirty path. The doubled subscription reads doubled, so it recomputes now.
  6. 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 →

Was this page helpful?