State
A state is a box with one rule: assignment notifies, mutation is free.
const C = (props, { state }) => {
const count = state(0); // State<number>
const rows = state([{ total: 1 }]); // contents stay MUTABLE
count.value++; // assignment → notifies
rows.value[0].total = 9; // mutation → silent, free
rows.touch(); // notify when YOU are done
return html`<b>${count}</b> <i>${() => rows.value[0].total}</i>`;
};
Updates are synchronous: when the assignment returns, the DOM is current.
Reading .value inside a live expression subscribes that binding; nothing
else re-runs.
Most reactive systems require replacement or proxying to detect changes, a reasonable default for forms and modest datasets. For spreadsheet-class data the cost profile shifts: cloning a million rows to update one cell is O(n) work for an O(1) change. LemonadeJS leaves structures mutable so the work stays proportional to what actually changed.
Mechanics
The engine refuses to guess when your mutation is finished, so you tell it:
// one cell of a huge dataset
rows.value[1832].total = 9; // O(1), no proxy trap, no clone
rows.touch(); // run the bindings that depend on rows
// a bulk pass
batch(() => {
for (const row of rows.value) row.flag = false;
rows.touch();
selected.value = null;
}); // every binding runs ONCE at the end
touch()notifies after in-place mutation. It is explicit on purpose: the notification point is visible in the code, greppable, and exactly where the author decided consistency is restored.batch()dedupes: any number of assignments and touches inside the callback trigger each affected binding once.- Plain objects throughout. No proxies, no wrappers:
rows.valueis your array, identity preserved. Pass it to a worker, serialize it, index it; nothing was swapped under you. - Development guards. Dev builds freeze state contents so an
accidental silent mutation throws (
LJS-201); the model is “mutate deliberately, then touch”, not “mutate and hope”. The cost of the model is the footgun the code names: in production nothing freezes, so a forgottentouch()updates nothing, silently. That silence is the documented price of free in-place mutation.
touch() reaches components. When a list re-runs under touch()
and an item is passed by reference, as in <${Card} item="${r}" />, the
card’s prop state is touched too, so the card re-reads the mutated
contents; the propagation recurses through nested components. Ownership
holds: a shared State passed as a prop is never touched
by propagation; its owner touches it. This is v5’s loop-scope visibility without v5’s
cost of writing framework fields onto your data; the list mechanics are
in Lists.
Module-scope state uses the same box: store(initial) (optionally
persisted); see Store.
In practice: the receipts
The datagrid block is the architecture’s proof, measured in real Chrome with budgets enforced in the repo’s probe suite:
- 100,000 rows mount in 5ms. The grid renders a window (20 DOM rows), and the data was passed by reference: no import-time copy.
- Mutate 5,000 of the 100,000 rows in place + one
touch(): 4ms to a current screen. - Scroll to row 100,000: the last row renders 0px off the viewport edge, windowing over the same untouched array.
The same model powers the gantt (drag commits mutate your task object,
then touch()), the schedule (events are your records; sorting maintains
your array), and inline editing in the datagrid (commits write through to
the caller’s row and fire onchange(row, column, value, old) with the
very object you supplied).
// the consumer side of the contract: your data stays yours
const tasks = store([{ label: 'Build', start: '2026-06-08', end: '2026-06-18' }]);
html`<${Gantt} data="${tasks}" editable
onchange="${(task, start, end) => save(task)}" />`;
// after a drag: task.start/task.end are already updated IN your object
Reference
const s = state(initial, onchange?); // component tool, instance-owned
s.value // read (subscribes inside live expressions)
s.value = next // assign: notifies dependents, synchronously
s.touch() // notify after in-place mutation
s.peek() // read WITHOUT subscribing
s.subscribe(cb) // imperative listener (untracked, pinned) → unsubscribe fn
batch(fn) // dedupe all notifications inside fn
store(initial, storageKey?) // module-scope state (+ optional localStorage)
Rules worth memorizing: assignment notifies; mutation requires touch();
subscribe callbacks are imperative (reads inside them never create
dependencies); never name a bound state value, because value.value is
unreadable. For derived values, reach for computed().