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:
- Non-function entries are state — the reactive
data (
items: [],count: 0). - Function entries are methods — each receives the
store handle as its first argument, conventionally named
s. A method that returns a value is a getter; one that mutatessis an action.
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().
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++).
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.
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.
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
| Key | Type | Notes |
|---|---|---|
persist | string | true | Mirror 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. |
history | true | number | Record undo snapshots of the declared fields. true keeps 50; a positive number caps the depth at that value. |
Members injected by history
| Member | Type | Behaviour |
|---|---|---|
.undo() | fn | Restore the previous snapshot and push the current one onto the redo stack. A no‑op when there is nothing to undo. |
.redo() | fn | Re‑apply the snapshot that undo() stepped past. A no‑op when the redo stack is empty. |
.clearHistory() | fn | Drop both stacks. The current values are untouched; canUndo and canRedo go false. |
.canUndo | boolean | Reactive — read it straight into a disabled prop. |
.canRedo | boolean | Reactive, 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.
| Option | Type | Notes |
|---|---|---|
expires | number | Date | string | A number is days from now; a Date or parseable string is an absolute expiry. |
maxAge | number | Seconds, floored. Overrides expires when both are present. |
path | string | Defaults to "/". Validated — a value that is not a plain cookie path is silently replaced with "/". |
domain | string | Validated as a hostname (up to 253 characters, optional leading dot). A value that fails is dropped. |
secure | boolean | Adds 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.
| Concept | Redux Toolkit | Zustand | Pinia | Aktion |
|---|---|---|---|---|
| Declaring it | createSlice + configureStore | create(fn) | defineStore(id, …) | cart = $store({ … }) |
| State | initialState | Fields returned by the creator | state() | Non‑function entries |
| Derived value | Selector / createSelector | Selector passed to the hook | getters | A method that returns: count: (s) => s.items.length |
| Mutation | Reducer + dispatch(action) | set(…) in an action | actions / $patch | A method that writes: add: (s, x) => { … } |
| Reading in a component | useSelector(…) | useStore(s => s.x) | store.x | cart.items — tracked automatically |
| Provider setup | <Provider store> | None | app.use(createPinia()) | None — the store is the module |
| Persistence | redux-persist | persist middleware | A persistence plugin | persist: "key" |
| Undo / redo | A history middleware | A history middleware | Hand‑rolled | history: true |
| Immutability | Immer inside reducers | Spread by hand | Direct mutation | Direct‑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
Hooks
Component-local state with $state / $memo, and your own custom hooks.
Forms
The store-backed $form engine — validation rules, error surfacing, and submitting to a server.
Actions
Event handlers, state writes, navigation, and emitting custom events.
Read the guide →