---
title: "Reactive State Management in LemonadeJS"
description: "Reactive state with synchronous updates and free in-place mutation: the architecture, mutation mechanics, and the measured 100,000-row performance receipts."
source: https://lemonadejs.com/docs/state/
---

# Reactive state

Reactive state is a box with one rule: **assignment notifies, mutation is
free**.

```javascript
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.

Assignment notifies. Mutation is silent until you `touch()`. `batch()` collapses many writes into one pass.

<!--example-->

```js
import { html, batch } from 'lemonadejs';

const App = (props, { state }) => {
    const count = state(0);
    const rows = state([{ name: 'Ana', total: 3 }, { name: 'Bruno', total: 5 }]);

    const bump = () => {
        rows.value[0].total++;          // in-place mutation: silent and free
        rows.touch();                   // notify when you are done
    };
    const reset = () => batch(() => {   // every binding runs once, at the end
        count.value = 0;
        rows.value.forEach((r) => (r.total = 0));
        rows.touch();
    });

    return html`<div>
        <p>count = <b>${count}</b> <button onclick="${() => count.value++}">count.value++</button></p>
        <ul>${() => rows.value.map((r) => html`<li>${r.name}: ${r.total}</li>`)}</ul>
        <button onclick="${bump}">Ana +1 (mutate, then touch)</button>
        <button onclick="${reset}">reset everything (batch)</button>
    </div>`;
};
```

## Mechanics

The engine refuses to guess when your mutation is finished, so you tell it:

```javascript
// 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](/docs/lists/).

Module-scope state uses the same box: `store(initial)` (optionally
persisted); see [Store](/docs/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).

```javascript
// 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

```javascript
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()](/docs/computed/).