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 via useSyncExternalStore(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.