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" }))

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 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.

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-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.

Live
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())
MemberTypePurpose
.valuesobjectCurrent field values (two‑way bindable).
.errors · .touchedobjectPer‑field error messages and touched flags.
.valid · .submittingbooleanWhole‑form status.
.setField(name, value)fnSet one field and clear its error.
.validate() · .validateField(name)fnRun validation now.
.touch(name)fnMark touched + validate that field.
.handleSubmit(extra?)fnTouch‑all → validate → onSubmit(values) when valid.
.reset()fnRestore 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