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.value is 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 forgotten touch() 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().