# Lume.js > Minimal reactive state management using only standard JavaScript and HTML — > no custom syntax, no build step, no framework lock-in. 1.46 KB universal > core (state/batch), 2.66 KB with DOM binding (bindDom/effect). Reactivity > that follows web standards: stores are Proxies over plain objects, DOM > binding is declarative via valid data-* attributes, updates are > microtask-batched per store with an explicit batch() escape hatch for > cross-store writes. Version: 2.4.0. Install: `npm install lume-js` or import from `https://cdn.jsdelivr.net/npm/lume-js/dist/index.min.mjs`. Key facts agents get wrong without reading the docs: arrays and nested objects must be REPLACED, never mutated in place (reference equality); nested objects need explicit state() wrapping; updates flush on the next microtask, not synchronously; effects only track keys read synchronously; data-* attribute values are state keys, never expressions. This file is the full documentation bundle: every guide, tutorial, and API page concatenated. Generated by scripts/build-llms.js — do not edit. ======================================================================== FILE: AGENT_GUIDE.md ======================================================================== # Lume.js — Agent Guide Instructions for AI coding agents (and fast-moving humans) **using Lume.js in an application**. Read this before writing any Lume code. For developing Lume.js itself, see `AGENTS.md` instead. - Machine-readable doc bundle: `llms.txt` (index) and `llms-full.txt` (this guide + every guide, tutorial, and API page in one file) ship with the npm package and live at the repo root. - Full human docs: ## Mental model (30 seconds) Lume is **standards-only reactivity**: a `Proxy`-based store (`state`), auto-tracking side effects (`effect`), and declarative DOM binding through plain `data-*` attributes (`bindDom`). No components, no virtual DOM, no compiler, no build step. HTML stays valid HTML; JavaScript stays standard JavaScript. You own the DOM structure; Lume keeps it in sync with state. ```javascript import { state, effect, bindDom, batch } from 'lume-js'; const store = state({ name: 'World', count: 0 }); bindDom(document.body, store); // wires all data-* attrs under body effect(() => { // auto-tracks store.count document.title = `Count: ${store.count}`; }); store.count++; // DOM + title update on next microtask ``` ```html

Hello, !

``` ## Package entries — import from the right place | Import | Size (gz) | Gives you | Use in | |---|---|---|---| | `lume-js` | 2.77 KB | `state`, `effect`, `bindDom`, `batch`, `withReadObserver` | browsers | | `lume-js/state` | 1.49 KB | `state`, `batch`, `withReadObserver` (no DOM code) | Node, workers, CLI, SSR | | `lume-js/addons` | pay per import | `computed`, `watch`, `repeat`, `persist`, `hydrateState`, `createCleanupGroup`, `debug` | optional patterns | | `lume-js/handlers` | pay per import | `show`, `classToggle`, `boolAttr`, `ariaAttr`, `stringAttr`, `className`, `on`, `htmlAttrs`, presets | extra `data-*` attributes | CDN (no build): `https://cdn.jsdelivr.net/npm/lume-js/dist/index.min.mjs` (also `/dist/state.min.mjs`, `/dist/addons.min.mjs`, `/dist/handlers.min.mjs`). ## The rules — violating any of these produces silently broken code 1. **Never mutate arrays or nested objects in place. Replace them.** Lume detects changes with reference equality (`Object.is`) on top-level keys only. ```javascript store.items.push(x); // ❌ silent — no update ever fires store.items = [...store.items, x]; // ✅ store.items = store.items.filter(t => t.id !== id); // ✅ remove store.items = store.items.map(t => t.id === id ? { ...t, done: true } : t); // ✅ update store.items = [...store.items].sort(cmp); // ✅ sort (copy first!) ``` 2. **Nested objects are not reactive unless explicitly wrapped.** ```javascript const s = state({ user: { name: 'Ada' } }); s.user.name = 'Z'; // ❌ silent const s2 = state({ user: state({ name: 'Ada' }) }); s2.user.name = 'Z'; // ✅ notifies // or replace the whole object: s.user = { ...s.user, name: 'Z' }; // ✅ notifies subscribers of 'user' ``` 3. **Updates are asynchronous (microtask-batched per store).** After a write, the DOM updates on the next microtask, not synchronously. In tests, flush before asserting: ```javascript store.count = 5; await Promise.resolve(); // or await new Promise(r => setTimeout(r)) expect(el.textContent).toBe('5'); ``` Multiple writes to the same store in one tick coalesce into one flush, and subscribers see only the final value of each key. 4. **Effects track only what they read synchronously.** Reads after an `await`, inside `setTimeout`, or inside event callbacks are NOT tracked. Read every dependency at the top of the effect body. Conditional reads re-track on every run (only the branch actually taken is tracked). Writes inside an effect do not create subscriptions — only reads do. 5. **Keep and call the dispose functions.** `effect()`, `bindDom()`, `watch()`, `repeat()`, `persist()`, `$subscribe()`, and `computed().subscribe()` all return a cleanup/unsubscribe function. Call it when the UI section goes away, or collect them: ```javascript import { createCleanupGroup } from 'lume-js/addons'; const group = createCleanupGroup(); group.add(effect(() => { /* … */ })); group.add(bindDom(panel, store)); group.dispose(); // dispose everything at once ``` Creating effects/subscriptions in a loop without cleanup eventually hits the per-key subscriber cap (1000) and logs a console error. 6. **Writing several stores from one event? Wrap it in `batch()`.** Per-store microtask batching already dedupes same-store writes; an effect reading N stores mutated in the same tick still runs once per store. `batch(fn)` flushes synchronously when the outermost batch ends, running cross-store effects exactly once. `fn` must be synchronous — writes after an `await` inside it are NOT batched. ```javascript batch(() => { cart.items = [...cart.items, item]; totals.sum += item.price; ui.message = 'Added!'; }); ``` 7. **Stores hold plain objects.** `state()` throws on non-objects, arrays, and frozen/sealed objects at the top level. `Map`/`Set` are not reactive (use arrays/objects). Class instances mostly work but private fields bypass the proxy. Functions stored on state stay plain (usable with the `on()` handler). Keys starting with `$` are reserved meta API (`$subscribe`, `$beforeFlush`); writes to `__proto__`/`constructor`/ `prototype` are blocked. 8. **`data-*` attribute values are state keys, never expressions.** `data-bind="user.name"`, `data-show="count > 0"` — ❌ not supported. Derive a flat key in JS instead: ```javascript effect(() => { store.hasItems = store.items.length > 0; }); ``` ```html
…
``` ## Choosing the right primitive | You want to… | Use | |---|---| | Derive a value **into the store** (for `data-bind`) | `effect(() => { store.full = store.a + store.b; })` | | Derive a read-only value consumed **outside** the store | `computed(() => store.a * 2)` → `.value`, `.subscribe(fn)`, `.dispose()` | | React to **one specific key** (analytics, localStorage, manual DOM) | `watch(store, 'key', fn)` — options `{ immediate: false }` to skip the initial call | | Run side effects when **anything read** changes | `effect(fn)` (auto-tracking) | | Side effect with **no magic tracking** | `effect(fn, [[store, 'key1', 'key2'], [other, 'k']])` (explicit deps, coalesced to one run per microtask) | | Render an **array** as DOM elements | `repeat(container, store, 'items', { key: it => it.id, … })` | | Low-level single-key subscription (no tracking) | `store.$subscribe('key', fn)` — calls immediately with current value | Anti-pattern: calling `$subscribe` for a key inside an `effect` that also reads that key — the logic runs twice. Pick one mechanism. ## Built-in `data-*` attributes (core `bindDom`) ```html
…
``` More attributes are opt-in handlers, passed to `bindDom`: ```javascript import { show, classToggle, stringAttr, on } from 'lume-js/handlers'; bindDom(root, store, { handlers: [show, classToggle('active'), stringAttr('href'), on('click')] }); ``` ```html …
…
Profile ``` Custom handlers are plain objects: `{ attr: 'data-tooltip', apply(el, val) { el.title = val ?? ''; } }`. `htmlAttrs()` is the one-import preset covering standard HTML + ARIA attrs. ## Lists with `repeat()` (keyed, element-reusing) ```html ``` ```javascript import { repeat } from 'lume-js/addons'; repeat('#todos', store, 'todos', { key: t => t.id, template: true }); ``` Imperative variant when you need custom DOM per row: ```javascript repeat(listEl, store, 'items', { element: 'li', key: it => it.id, create: (it, el) => { /* build row structure once */ }, update: (it, el) => { /* sync data on every change */ }, }); ``` `repeat` diffs by `key` against the previous array — which is why rule #1 (immutable array updates) is non-negotiable. ## Canonical app skeleton ```html

Loading…

``` ```javascript import { state, effect, bindDom, batch } from 'lume-js'; import { repeat, watch, persist, createCleanupGroup } from 'lume-js/addons'; import { show } from 'lume-js/handlers'; const store = state({ query: '', loading: false, results: [] }); const group = createCleanupGroup(); group.add(bindDom(document.body, store, { handlers: [show] })); group.add(repeat('#results', store, 'results', { key: r => r.id, template: true })); group.add(persist(store, 'my-app', { keys: ['query'] })); // localStorage sync group.add(watch(store, 'query', async (q) => { store.loading = true; const results = await fetchResults(q); batch(() => { // one flush for both writes store.results = results; // replace, never mutate store.loading = false; }); }, { immediate: false })); ``` ## Debugging & testing - Write/read logging is a plugin: ```javascript import { withPlugins, createDebugPlugin, debug } from 'lume-js/addons'; const store = withPlugins(state({ count: 0 }), [createDebugPlugin({ label: 'cart' })]); debug.enable(); // global on/off switch; also debug.filter('key'), debug.stats() ``` - Nothing updates? Check, in order: (1) mutated an array/nested object in place (rule 1/2), (2) asserted synchronously before the microtask flush (rule 3), (3) the read happened after an `await` inside an effect (rule 4), (4) `bindDom` was called before the element existed, or on a root that doesn't contain it. - Effect runs too often? Reading many stores in one effect while writing them separately — wrap the writes in `batch()` (rule 6). - Console error about subscriber limit (1000/key) or max flush iterations (100) = subscription leak in a loop, or an effect writing a key it also reads (infinite cascade). - Tests: jsdom + vitest work fine. Write state → `await Promise.resolve()` → assert DOM. See `docs/guides/testing.md`. ## SSR / no-DOM environments Import `state`/`batch` from `lume-js/state` (1.49 KB, zero DOM references) in Node/workers/CLI. For server-rendered pages, inline initial state as ` ``` DOM-free kernel only (servers, workers, embedded): ```html ``` ### Via NPM (Recommended for bundlers) ```bash npm install lume-js ``` ```javascript import { state, bindDom } from 'lume-js'; // browser: full core import { state, batch } from 'lume-js/state'; // Node/CLI/workers: 1.49 KB kernel ``` > **→ Using Lume without a DOM?** See the [Universal core guide](docs/guides/universal-core.md). ### Browser Support | Browser | Minimum version | |---------|-----------------| | Chrome | 80+ | | Firefox | 74+ | | Safari | 13.1+ | | Edge | 80+ | | IE11 | ❌ Not supported | The floor comes from optional chaining / nullish coalescing (ES2020) used in the source — shipped un-transpiled, true to no-build. IE11 cannot be polyfilled regardless: Lume uses `Proxy`. --- ## Quick Start **HTML:** ```html

Hello, !

``` **JavaScript:** ```javascript import { state, bindDom } from 'lume-js'; const store = state({ name: 'World' }); bindDom(document.body, store); ``` That's it — two-way binding, no build step, valid HTML. ### Lists? Also just HTML. ```html ``` ```javascript import { repeat } from 'lume-js/addons'; repeat('#todos', store, 'todos', { key: t => t.id, template: true }); ``` The row structure is a standard `