Store
A store is a state that lives at module scope instead of inside a
component. Same box, same rules: assignment notifies, mutation is free,
touch() when you are done:
import { store } from 'lemonadejs';
export const session = store({ user: null }); // any component: ${session}
export const theme = store('light', 'app-theme'); // persisted to localStorage
Every component that reads session.value in a live expression subscribes
that binding; when a component unmounts, its subscriptions are disposed
with it. There is nothing else to learn, because a store is a
State.
The problem
Component-scoped state does not cross the component tree without a dedicated transport. Common patterns (threading props, context, injectable services) each introduce a separate state model beside the component one, with its own update vocabulary and a boundary the author crosses on every interaction.
How LemonadeJS solves it
store() is the component state tool minus the component. One reactive
primitive, two scopes:
// store.js: state and the actions that own it, one module
import { store } from 'lemonadejs';
export const cart = store([]);
export const add = (item) => {
cart.value = [...cart.value, item]; // assignment notifies
};
export const clear = () => {
cart.value = [];
};
// anywhere: no provider, no wrapper, no dispatch
import { cart, add } from './store.js';
const Header = () => html`<span>${() => cart.value.length} items</span>`;
const Buy = (props) => html`<button onclick="${() => add(props.item)}">Add</button>`;
This module shape (a store plus exported action functions) is the
deliberate replacement for the action/reducer/dispatch pipeline (and for
v5’s string-keyed Sugar set/get/dispatch). The actions are plain
functions; “dispatching” is calling them; the import list is the complete
inventory of who can write.
What carries over from the State contract, because it is the same class:
- Updates reach exactly the dependent bindings. A store written from one component updates only the expressions that read it, in any other component anywhere in the page (pinned in the engine’s suite), and it stays live across unmount/remount cycles of its consumers.
- Big data stays cheap: mutate in place,
touch()once,batch()for bulk passes. subscribe(cb)is untracked (addEventListener semantics) and returns its unsubscribe function;peek()reads without subscribing: the same two calls that adapt a store to React viauseSyncExternalStore(s.subscribe, s.peek).- Destroy accounting holds: the leak gate asserts that a shared
store’s subscription set returns to zero after 200 mount/unmount cycles
of its consumers; see Destroy. The one caller-owned
case is a manual
subscribe()outside a component: you hold the unsubscribe function.
Persistence is the second argument. store(initial, 'key') reads the
key from localStorage at creation (falling back to initial if the
entry is missing or unparseable) and writes JSON back on every
notification: assignments and touch(). Storage failures (quota,
privacy mode, no localStorage at all) degrade silently to in-memory:
const a = store({ count: 1 }, 'test-key');
a.value = { count: 7 }; // localStorage now holds {"count":7}
const b = store({ count: 0 }, 'test-key');
b.value; // { count: 7 }, restored on creation
That example is the engine’s own test, verbatim.
No action history or replay. v6 stores are mutable, so there is no
action log or time-travel replay. inspect() returns the live component
tree with state values as JSON; synchronous updates make stepping in a
debugger straightforward.
SSR. store() at module scope is shared per JavaScript module
instance. In an SSR process that means shared across requests;
server-rendered apps must scope stores per request themselves.
In practice
The Studio blocks consume stores through their props, the same two-way
bind that takes a local state takes a store, which is how one value is
shared between a block and the rest of the page:
// the dropdown demo: one store, bound to the component AND rendered beside it
const team = store('');
html`<${Dropdown} data="${countries}" bind="${team}" placeholder="Pick a country" />
<p>bound value: <b>${team}</b></p>`;
// picking an option updates the store; writing team.value = 'br'
// updates the dropdown, silently (no onchange echo)
// the calendar demo: a persisted date would be ONE argument away
const date = store('2026-06-15'); // store('2026-06-15', 'last-date')
html`<${Calendar} bind="${date}" format="DD/MM/YYYY" />`;
The block test suites use the same gesture the other way around, binding a store, writing it externally, and asserting the component followed (switch, progress, wheel, transferlist), so “a store is a state everywhere a state is accepted” is a tested property, not a convention.
Reference
import { store } from 'lemonadejs';
const s = store(initial); // State<T> at module scope
const p = store(initial, 'key'); // + localStorage: restore on creation,
// persist on every notification
// the full State API applies:
s.value // read (subscribes inside live expressions)
s.value = next // assign: notifies, synchronously
s.touch() // notify after in-place mutation (also persists)
s.peek() // read without subscribing
s.subscribe(cb) // untracked listener → unsubscribe fn
batch(fn) // dedupe notifications across stores and states
Rules worth memorizing: a store outlives every component, so bindings from
components are disposed with them, manual subscribe() is yours to
unsubscribe; persistence is JSON (Date, Map, functions will not
round-trip); computed() is a component tool, so derive from stores at use
sites. For component-owned services rather than shared data, see
Sugar.