Core concept

Reactivity & rendering.

Aktion tracks state at path granularity and re-renders through two gates. Knowing which gate an update takes is the difference between a surgical patch and a full-tree re-render. This page explains exactly what triggers each path and how to keep your app fine-grained.

The one thing to remember. Only top-level $name = value atoms and $store fields take the fine-grained path. The $state/$memo hooks, every $http lifecycle transition, timers, effect-driven writes, and $emit all force a full-tree re-render (with memoization disabled). If you build a hook-heavy app you get correct output, but not the headline fine-grained perf profile.

The reactive model

A reactive value in Aktion is an atom: a named cell in a single global store. Reading an atom while rendering subscribes the surrounding element to that atom’s path — not just the atom name, but the exact property you touched ($user.profile.name subscribes to that path, not all of $user). When you later write the same path, only the subscribers of that path are invalidated.

This is the auto-tracking of MobX with the path-granularity of Solid, and it needs no selectors or dependency arrays. The catch: it only applies to the atom/store style of state.

Three lines capture the whole loop — declare, read, write:

$count = 0                                  // 1. declare an atom (top level)

function Counter() {
  return Button(`Clicked ${$count} times`, {  // 2. reading subscribes this Button to "count"
    onClick: () => { $count = $count + 1 }     // 3. writing invalidates only "count" readers
  })
}
$app(Counter())

No useState, no setter, no dependency array. The $count read inside Counter is what wires the subscription; the $count = … write in the handler is what fires it. Everything else on the page is left alone.

The two render gates

GateHow it’s triggeredWhat re-runs
Fine-grained Writing a top-level $name = value atom or a $store field. Only the elements/components that read the affected path. Memoized siblings are untouched.
Full re-render Anything that routes through notify() (see the list below). The entire program tree re-executes with memoization disabled, then the morph reconciler patches the DOM.

What forces a full re-render

Every one of these calls notify(), which requests a full-tree re-render:

None of these is wrong — the morph reconciler preserves focus, selection, scroll, and input values across the patch, so a full re-render is visually seamless. It is simply more work than a path-scoped update.

How to keep renders fine-grained

Live — path-scoped atom update
$user = { name: "Ada", role: "admin" }
function Profile() {
  return Column([
    // Reads only $user.name — a write to $user.role won't re-render this.
    Text(`Name: ${$user.name}`, { variant: "large-heavy" }),
    Button("Rename", { variant: "primary", onClick: () => { $user.name = "Grace" } }),
  ], { gap: "md" })
}
$app(Profile())

The single most common performance mistake is reassigning a whole object when you meant to change one field. Write the narrowest path:

Avoid — invalidates every reader of $user
// Re-creates the object, so any
// component that read ANY part of
// $user must re-render.
$user = { ...$user, name: "Grace" }
Prefer — invalidates only "user.name"
// Touches one path. Only the
// components that read $user.name
// re-render.
$user.name = "Grace"

$x = expr: declare vs. assign

The same syntax means different things depending on where it appears. This is the highest-stakes semantic in the language, so internalise it:

WhereMeaning
Top level Declares a reactive atom. A non-literal right-hand side becomes a computed derivation that recomputes when its inputs change.
Inside a PascalCase component A per-instance state declaration: the initializer runs once and the value persists across renders.
Inside an action / effect / lambda A plain reactive write to an existing atom.

The runtime has a render guard: a $name = … write that runs in render position is applied without scheduling a re-render (to prevent infinite loops) and logs a warning. If you see that warning, you are assigning state during render — move the write into an event handler or an effect, or seed it with a PascalCase component / the $state hook instead.

Derived values with $util.derived

A top‑level $x = expr with a non‑literal right‑hand side is already a computed derivation. When you want that same recompute‑from‑inputs behaviour inside a component body — without an explicit dependency list — reach for $util.derived(fn). It re‑evaluates whenever the atoms its function reads change, and memoizes within the component.

function CartSummary() {
  subtotal = $util.derived(() => $util.sum($cart.map(i => i.price * i.qty)))
  shipping = $util.derived(() => subtotal > 50 ? 0 : 6)
  return Column([
    Text(`Subtotal: ${$util.currency(subtotal)}`),
    Text(`Shipping: ${$util.currency(shipping)}`),
  ])
}

Use $memo when you need an explicit dependency array or want to cache an expensive pure computation; $util.derived is the lighter choice for plain read‑through values.

Reactive environment globals

$util exposes a set of reactive getters that let the UI respond to the browser environment without manual listeners or host glue. Reading one re‑renders the component when it changes; listeners attach lazily on first read (a program that never touches them pays nothing) and updates are requestAnimationFrame‑coalesced.

GetterShapeUse it for
$util.scroll{ x, y, progress, direction }Scroll progress bars, show/hide on scroll. progress is 0–1.
$util.viewport{ width, height }Pixel‑exact responsive decisions.
$util.breakpoint{ active, width, sm, md, lg, xl }Named breakpoints (640/768/1024/1280). Flags are cumulative.
$util.media{ prefersDark, prefersReducedMotion, online, pointer, portrait }Honour user preferences and connection state.
$util.mouse{ x, y }Spotlight / cursor‑follow effects.
$util.url{ path, params, query, hash, … }Reactive URL (see Routing).
Live — resize the window
function Responsive() {
  bp = $util.breakpoint
  return Column([
    Text(`Active breakpoint: ${bp.active}`, { variant: "large-heavy" }),
    Text(`Viewport: ${$util.viewport.width} × ${$util.viewport.height}`),
    Grid($util.range(1, bp.lg ? 4 : (bp.md ? 2 : 1)).map(n =>
      Card([Text(`Cell ${n}`)])
    ), { columns: bp.lg ? 4 : (bp.md ? 2 : 1) }),
  ], { gap: "md" })
}
$app(Responsive())

Branch animations on $util.media.prefersReducedMotion and layout on $util.breakpoint.active to keep a single program adaptive across devices.

Which state primitive should I reach for?

Aktion gives you four ways to hold state. They all work, but they sit on different render gates. Use this table to pick the one that keeps your app on the fine-grained path:

Use…When the state is…Render gate
Top-level atom $count = 0 Read by more than one component, or read deep in the tree. Fine-grained — the default, fastest choice.
$store Shared, structured app state (cart, user, settings) with many fields. Fine-grained, per-field. See Global state.
$state hook Genuinely component-local and the subtree under it is small. Full re-render of that component. See Hooks.
$memo An expensive pure computation you want cached against explicit deps. Recompute forces a full re-render. See Hooks.

Rule of thumb. Reach for a top-level atom or $store first. Drop to $state only when the state truly belongs to one small component and never needs to be read elsewhere — then the full re-render is cheap because the subtree is tiny.

Next