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.
On this page
The reactive model · The two render gates · What forces a full re-render · Keeping renders fine-grained · Declare vs. assign · Derived values · Reactive environment globals · Which state primitive?
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
| Gate | How it’s triggered | What 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:
- Every
$statesetter (setCount(…)). - Every
$memorecomputation that changes its value. - Every
$httplifecycle transition (loading → data, error, refetch). setTimeout/setIntervalcallbacks that touch state.- Effect bodies that mutate state.
$emitdispatches.
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
- Prefer top-level atoms (
$count = 0) and$storefor state that many components read. Reads subscribe by path; writes invalidate only that path. - Reach for component-local
$statewhen the state is genuinely local and the component subtree is small — the full re-render is then cheap because the tree under it is small. - Write narrow paths:
$user.name = "Ada"invalidates only readers ofuser.name, whereas reassigning the whole object ($user = {…}) invalidates everyone who read any part of$user. - Let components memoize: a PascalCase component whose props are unchanged is skipped on a fine-grained update. Full re-renders bypass this, so the fewer full re-renders you trigger, the more memoization pays off.
$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:
// Re-creates the object, so any
// component that read ANY part of
// $user must re-render.
$user = { ...$user, name: "Grace" }
// 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:
| Where | Meaning |
|---|---|
| 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.
| Getter | Shape | Use 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). |
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.