Core concept

Global state.

$store({ … }) bundles app-wide state with the actions and getters that operate on it — the role Redux, Zustand, and Pinia play in other ecosystems, expressed in Aktion. One store is shared by every component that reads it, so there is no prop drilling, and updates stay fine-grained: a component re-renders only for the slice it actually reads.

Why a store

A top-level $atom is already global — any component can read and write it. A store adds the missing piece: organisation. It keeps a slice of related state together with the functions that change it, behind one named, discoverable handle. Reach for a store when state is shared across distant parts of the tree (a cart, the signed-in user, a theme, a notification queue); keep using a component’s local $state / $name = … when only that component owns the state.

Defining a store

Call $store({ … }) at the top level and bind it to a name. Inside the object, the rule is simple:

cart = $store({
  // state
  items: [],
  coupon: null,

  // getters — derive a value from state
  count: (s) => s.items.length,
  total: (s) => $util.sum(s.items.map(i => i.price)),

  // actions — mutate state with `s.field = …`
  add: (s, item) => { s.items = [...s.items, item] },
  remove: (s, id) => { s.items = s.items.filter(i => i.id != id) },
  clear: (s) => { s.items = [] },
})

The handle is an app-global singleton — the same store no matter where or how often you reference cart — and its methods are reference-stable across renders, so passing cart.add as a prop never defeats memoization.

Reading state

Read a field with store.field. Reads are fine-grained: cart.items subscribes the reader to the items slice only, exactly like a $state path read. Call a getter-method with store.getter().

Live
counter = $store({
  count: 0,
  increment: (s) => { s.count = s.count + 1 },
  decrement: (s) => { s.count = s.count - 1 },
  isEven: (s) => s.count % 2 === 0,
})

function CounterView() {
  return Column([
    Text(`Count: ${counter.count} (${counter.isEven() ? "even" : "odd"})`, { variant: "large-heavy" }),
    Row([
      Button("Decrement", { onClick: counter.decrement }),
      Button("Increment", { variant: "primary", onClick: counter.increment }),
    ], { gap: "sm" }),
  ], { gap: "md" })
}
$app(CounterView())

Actions & getters

A method’s first parameter s is the store. Read through it (s.items) and write through it (s.items = [...s.items, item]) — member writes go through the same immutable, reactive path as $state, so subscribers wake up. Extra parameters after s are the call-site arguments: cart.add(item) runs add(s, item). The compound operators work too (s.count += 1, s.count++).

Live
cart = $store({
  items: [],
  count: (s) => s.items.length,
  total: (s) => $util.sum(s.items.map(i => i.price)),
  add: (s, item) => { s.items = [...s.items, item] },
  clear: (s) => { s.items = [] },
})

function Menu() {
  return Row([
    Button("Add Latte", { variant: "primary", onClick: () => cart.add({ name: "Latte", price: 4.5 }) }),
    Button("Add Muffin", { onClick: () => cart.add({ name: "Muffin", price: 3 }) }),
    Button("Clear", { onClick: cart.clear }),
  ], { gap: "sm" })
}

function Summary() {
  return Card([
    Text(`${cart.count()} items`, { variant: "large-heavy" }),
    Text(`Total: ${$util.format(cart.total(), "currency")}`, { tone: "primary" }),
  ])
}
$app(Column([Menu(), Summary()], { gap: "md" }))

Member writes go through the guarded state path

Two writes are refused rather than performed. A path segment named __proto__, constructor or prototype is rejected outright — the write is a silent no‑op — and an array index above 1 000 000 is refused instead of allocating that many slots. Both matter when a key comes from a server payload (s[key] = value); see the sink table.

Sharing across components

Because the store is global, any component can read it or call its actions directly — no passing props down through intermediate components, no context provider to wrap the tree. In the demo above Menu and Summary are siblings with no relationship; both talk to the same cart. Clicking a button in one updates the other.

And it stays efficient: reads are fine-grained and per-component. Only the components that read the slice you changed re-render — a component that reads cart.total() is left alone when you change an unrelated field.

Two-way binding

Pass a store field as a value prop and the runtime wires the input’s change handler back into the store automatically — the same implicit binding you get with a $state reference.

Live
form = $store({
  name: "",
  email: "",
})

function SignupForm() {
  return Column([
    Input("name",  { placeholder: "Name",  value: form.name }),
    Input("email", { placeholder: "Email", value: form.email }),
    Text(`Hello, ${form.name == "" ? "stranger" : form.name}!`, { variant: "large-heavy" }),
  ], { gap: "md" })
}
$app(SignupForm())

Persistence

Add a persist key and the store mirrors its data to storage — declared fields hydrate from the saved snapshot before the first render, so the restored values are what the user sees, and every later change writes back. persistIn: "session" swaps localStorage for per‑tab sessionStorage.

prefs = $store({
  theme: "system",
  density: "comfortable",
  persist: "prefs",        // ← the storage key; survives a page reload
  persistIn: "local"       // "local" (default) | "session"
})

Only the fields the store declares are restored: a renamed or removed key in the snapshot is ignored, and a newly added field keeps its code default. That is what makes it safe to evolve the shape over time.

Prefer an explicit string key. persist: true works, but it derives the key from the call site — move the $store call to a different line and the saved data is orphaned. Persistence is also a no‑op wherever Web Storage is missing, so an SSR render neither reads nor writes.

Undo & redo

Set history: true and the store records a snapshot before each user‑driven change, keeping the last 50. Pass a number instead to cap the depth. That injects undo() / redo() / clearHistory() methods and reactive canUndo / canRedo flags — perfect for an editor or a form wizard. A fresh edit clears the redo stack.

Live
doc = $store({
  title: "Untitled",
  history: true,
})

function Editor() {
  return Column([
    Input("title", { value: doc.title }),
    Row([
      Button("Undo", { onClick: () => doc.undo(), disabled: !doc.canUndo, variant: "ghost" }),
      Button("Redo", { onClick: () => doc.redo(), disabled: !doc.canRedo, variant: "ghost" }),
    ], { gap: "sm" }),
    Text(`Now: ${doc.title}`, { variant: "small", tone: "muted" }),
  ], { gap: "md" })
}
$app(Editor())

Persistence and history compose: a store can both survive reloads and offer undo. Snapshots cover only declared user fields, so the two features never step on each other.

Store API

A store’s object is mostly your own state and methods, but three keys are configuration rather than data: they never appear as fields, and history injects members of its own. Here is the whole surface in one place.

Configuration keys

KeyTypeNotes
persiststring | trueMirror the declared fields to Web Storage. A string is the storage key; true derives one from the call site. Omitted → no persistence.
persistIn"local" | "session"Which backend to use. "local" is the default; anything other than "session" falls back to it.
historytrue | numberRecord undo snapshots of the declared fields. true keeps 50; a positive number caps the depth at that value.

Members injected by history

MemberTypeBehaviour
.undo()fnRestore the previous snapshot and push the current one onto the redo stack. A no‑op when there is nothing to undo.
.redo()fnRe‑apply the snapshot that undo() stepped past. A no‑op when the redo stack is empty.
.clearHistory()fnDrop both stacks. The current values are untouched; canUndo and canRedo go false.
.canUndobooleanReactive — read it straight into a disabled prop.
.canRedobooleanReactive, same as above. Cleared by any fresh edit.

Restoring a snapshot does not itself count as an edit, so undo and redo never record each other. One store can use every key at once:

prefs = $store({
  theme: "system",
  density: "comfortable",

  persist: "prefs",        // localStorage key
  persistIn: "local",      // "local" (default) | "session"
  history: 20,             // true = 50 snapshots; a number caps the depth

  setTheme: (s, t) => { s.theme = t }
})

$app(Column([
  Select("theme", {
    label: "Theme",
    value: prefs.theme,
    items: [
      SelectItem("system", { label: "System" }),
      SelectItem("light", { label: "Light" }),
      SelectItem("dark", { label: "Dark" })
    ]
  }),
  Row([
    Button("Undo", { onClick: () => prefs.undo(), disabled: !prefs.canUndo, variant: "ghost" }),
    Button("Redo", { onClick: () => prefs.redo(), disabled: !prefs.canRedo, variant: "ghost" }),
    Button("Forget history", { onClick: () => prefs.clearHistory(), variant: "ghost" })
  ], { gap: "sm" }),
  Text(`Saved as "${prefs.theme}" — reload the page and it is still there.`, { variant: "small", tone: "muted" })
], { gap: "md" }))

canUndo is false on the first render and becomes true the moment the select changes, so the toolbar enables itself without any bookkeeping of yours.

The storage namespace

When you want to persist one value rather than a whole store, use the storage namespace. It wraps the three browser backends behind one set / get / remove / clear API, and it is the vetted path — the only persistence surface still reachable once you narrow globals with setGlobalAccessPolicy("safe").

storage.set("draft", { title: $title, body: $body })   // bare storage.* = localStorage
storage.get("draft")                                   // → { title: …, body: … } — JSON round-trips
storage.session.set("activeTab", $tab)                 // per-tab sessionStorage
storage.local.remove("draft")
storage.cookies.set("uid", $id, { expires: 7 })        // expires in DAYS

$storage.set("draft", $draft)                          // the $-sigil spelling is identical

Non‑string values round‑trip through JSON, so arrays and objects persist without manual serialisation; a value that is already a string passes through untouched, which keeps legacy raw‑string keys readable. Nothing throws — set/remove/clear return false and get returns null when storage is blocked, full, or absent (private mode, SSR).

Cookies

storage.cookies takes the standard attributes, with two conversions worth remembering: expires as a number means days, while maxAge is in seconds and wins when both are set.

OptionTypeNotes
expiresnumber | Date | stringA number is days from now; a Date or parseable string is an absolute expiry.
maxAgenumberSeconds, floored. Overrides expires when both are present.
pathstringDefaults to "/". Validated — a value that is not a plain cookie path is silently replaced with "/".
domainstringValidated as a hostname (up to 253 characters, optional leading dot). A value that fails is dropped.
securebooleanAdds Secure. Required by browsers alongside SameSite=None.
sameSite"Strict" | "Lax" | "None"Case‑insensitive. Always emitted, defaulting to Lax; an unrecognised value also falls back to Lax.
storage.cookies.set("a", "1")
// → a=1; path=/; samesite=Lax                      ← SameSite is always written

storage.cookies.set("b", { x: 1 }, { path: "/app", domain: "example.com", secure: true, sameSite: "none", maxAge: 60 })
// → b=%7B%22x%22%3A1%7D; max-age=60; path=/app; domain=example.com; secure; samesite=None

storage.cookies.get("b")   // → { x: 1 }  — the JSON round-trip works here too

The name and value are percent‑encoded, and the attributes are validated rather than interpolated raw, so a path carrying its own ; Domain=… cannot smuggle an attribute in. The always‑on SameSite is a change in behaviour: previously it was omitted when you omitted it.

remove(key, options) must be given the same path and domain as the original set, or the browser will not match the cookie and it will still be there.

Storage is host‑visible, not private

Never put a secret in it. Everything in localStorage, sessionStorage and the cookie jar is readable by every script on the origin, including the embedding page. Security has the trust model and the full sink‑to‑sanitiser table — read it before you persist anything that came from a user.

Where to call $store

Call it at the top level. A store is keyed by its call site, not by the component that ran it, so the first evaluation creates it and every later reference — from any component, on any render — gets the same handle back.

Calling it inside a component body is legal and does work, but it is not per‑instance state. Two instances rendered from the same $store line share one store and one backing atom, so they can never hold different values — and the binding is scoped to that body, so no other component can reach it.

A store inside a component is still one store

Use $state for per‑instance state. Two Counter()s whose body calls $store({ n: 0 }) read and write the same n. The config object is also evaluated only on the first call, so defaults computed from state are frozen at that moment.

Stores live as long as the program. Loading a new program rebuilds the context, which recreates every store from its declared defaults — unless persist hydrates it again.

Forms with $form

Forms have their own store‑backed primitive. $form({ values, rules, onSubmit }) holds the field values, runs $util.rules validators, tracks touched / dirty / valid / submitting, and submits exactly once.

It is the same reactive machinery, so everything on this page about fine‑grained reads and two‑way binding applies to it — but the API is large enough to deserve its own guide. Read the Forms guide →

Store vs. local state

Stores and component-local state share one reactive engine; choose by who owns the data.

$store({...})Local $state / $name = …
Scope App-global — one shared instance, read anywhere. Per component instance — two Counter()s hold their own.
Best for State shared across distant components: cart, current user, theme, toasts. State only one component owns: a toggle, an input draft, a hover flag.
Actions Colocated methods (cart.add(item)), encapsulated with the data. Inline handlers or the $state setter (setCount(c => c + 1)).
Reactivity Identical — fine-grained per-path, per-component re-rendering, two-way binding.

Mixing the two is normal. A component reads the store for shared data and keeps its own presentation state locally, and derived values go in a $memo so they are recomputed only when their inputs move.

cart = $store({
  items: [{ name: "Latte", price: 4.5 }, { name: "Muffin", price: 3 }],
  total: (s) => $util.sum(s.items.map(i => i.price)),
  cheapest: (s) => $util.min(s.items.map(i => i.price))
})

function CartSummary(vatRate) {
  const gross = $memo(() => cart.total() * (1 + vatRate), [cart.total(), vatRate])
  return Column([
    Text(`Subtotal ${$util.currency(cart.total(), "EUR")}`),
    Text(`With VAT ${$util.currency(gross, "EUR")}`, { variant: "body-heavy" }),
    Text(`Cheapest item ${$util.currency(cart.cheapest(), "EUR")}`, { variant: "small", tone: "muted" })
  ], { gap: "xs" })
}

$app(CartSummary(0.19))

Note that the aggregate helpers take an array: $util.min(s.items.map(i => i.price)) is the cheapest price, while $util.min(cart.total()) passes a single number and returns 0. The same applies to sum, avg, max and count.

Coming from Redux, Zustand or Pinia

A store is the same idea those libraries implement, minus the wiring: there is no provider to mount, no reducer indirection, and no selector function, because a plain store.field read already subscribes the component to that one path.

ConceptRedux ToolkitZustandPiniaAktion
Declaring itcreateSlice + configureStorecreate(fn)defineStore(id, …)cart = $store({ … })
StateinitialStateFields returned by the creatorstate()Non‑function entries
Derived valueSelector / createSelectorSelector passed to the hookgettersA method that returns: count: (s) => s.items.length
MutationReducer + dispatch(action)set(…) in an actionactions / $patchA method that writes: add: (s, x) => { … }
Reading in a componentuseSelector(…)useStore(s => s.x)store.xcart.items — tracked automatically
Provider setup<Provider store>Noneapp.use(createPinia())None — the store is the module
Persistenceredux-persistpersist middlewareA persistence pluginpersist: "key"
Undo / redoA history middlewareA history middlewareHand‑rolledhistory: true
ImmutabilityImmer inside reducersSpread by handDirect mutationDirect‑looking writes; the runtime rebuilds each level immutably

The habit worth unlearning is the selector. Aktion tracks reads at the path level, so cart.items in one component and cart.coupon in another already re‑render independently — wrapping either in a memoised selector buys nothing.

Next