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" }))
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 on mount, and every change
writes back. A string is the storage key; true derives one.
persistIn: "session" swaps localStorage for
per‑tab sessionStorage.
prefs = $store({
theme: "system",
density: "comfortable",
persist: "prefs", // ← survives a page reload
persistIn: "local" // "local" (default) | "session"
})
Methods and unknown/renamed keys are never written; new fields keep their code defaults when an older snapshot loads, so it’s safe to evolve the shape over time.
Undo & redo
Set history: true (or a number to cap the depth) and the store
records a snapshot before each user‑driven change. That adds
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-soft" }),
], { 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.
Forms with $form
$form is a store‑backed form engine — managed values,
validation, touched‑tracking, and submit handling in one handle.
Two‑way bind inputs to form.values.<field>, declare
per‑field rules using the
$util.rules
validators, and let handleSubmit() touch‑all, validate, and
call onSubmit only when valid.
signup = $form({
values: { email: "", password: "" },
rules: {
email: [$util.rules.required(), $util.rules.email()],
password: [$util.rules.required(), $util.rules.minLength(8)],
},
onSubmit: (v) => { $done = v.email },
})
function Signup() {
return Column([
Input("email", { label: "Email", value: signup.values.email, error: signup.errors.email }),
Input("password", { label: "Password", type: "password", value: signup.values.password, error: signup.errors.password }),
Button("Create account", { onClick: () => signup.handleSubmit() }),
$done ? Alert(`Welcome, ${$done}!`, { tone: "success" }) : Text(""),
], { gap: "md" })
}
$app(Signup())
| Member | Type | Purpose |
|---|---|---|
.values | object | Current field values (two‑way bindable). |
.errors · .touched | object | Per‑field error messages and touched flags. |
.valid · .submitting | boolean | Whole‑form status. |
.setField(name, value) | fn | Set one field and clear its error. |
.validate() · .validateField(name) | fn | Run validation now. |
.touch(name) | fn | Mark touched + validate that field. |
.handleSubmit(extra?) | fn | Touch‑all → validate → onSubmit(values) when valid. |
.reset() | fn | Restore initial values, clear errors/touched. |
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. | |
You can freely mix them: derive a top-level computed value from a store
($cheapest = $util.min(cart.total())), or read a store inside a
custom hook.
Next
Hooks
Component-local state with $state / $memo, and your own custom hooks.
Language
Reactive state, fine-grained dependency tracking, and per-component re-rendering.
View the reference → BehaviourActions
Event handlers, state writes, navigation, and emitting custom events.
Read the guide →