Hooks.
A function whose name starts with $ is a hook
— the use* convention you know from React. Hooks are
how a component holds local, per-instance state. Five are built
in — $state (useState),
$memo (useMemo), $ref
(useRef), $reducer (useReducer),
and $id (useId) — and you compose
your own by declaring function $name(…).
What is a hook
Hooks run inside a component — a function used in
render position, such as $app(Counter()). Each time a
component renders, its hooks run in the same order and reconnect to that
instance’s own state slots. Two Counter() siblings
therefore keep entirely independent state, exactly as two React
components would.
There are only two things to remember, both inherited from React:
- Call hooks at the top level of a component (or
another hook) — never inside an
if, a loop, or a callback. Slots are matched by call order across renders. - Hook state is local to the instance and resets when the component leaves the screen.
$state, $memo, $ref,
$reducer, and $id are reserved names — you
can’t shadow them with a custom hook.
$state — local reactive state
$state(initial) returns a [value, setValue]
pair, just like React’s useState. Destructure it,
render the value, and call the setter from an event handler to update it.
const [value, setValue] = $state(initialValue)
| Part | Meaning |
|---|---|
value | The current state. Reading it subscribes the component, so it re-renders when the value changes. |
setValue(next) | Replace the value with next. |
setValue(prev => next) | Functional update — derive the next value from the previous one. Use this when the new value depends on the old (e.g. counters). |
initialValue | Evaluated once, on the first render. Later renders return the stored value, so user edits persist. |
Updating is a no-op when the value is unchanged (Object.is),
so a redundant setValue won’t trigger an extra render.
function Counter() {
const [count, setCount] = $state(0)
return Column([
Text(`Count: ${count}`, { variant: "large-heavy" }),
Row([
Button("Decrement", { onClick: () => setCount(c => c - 1) }),
Button("Increment", { variant: "primary", onClick: () => setCount(c => c + 1) }),
], { gap: "sm" }),
], { gap: "md" })
}
$app(Counter())
$memo — cached values
$memo(() => compute(), [deps]) caches the result of a
computation and only recomputes it when one of its dependencies changes
— React’s useMemo. Use it to avoid repeating
expensive work (filtering, sorting, formatting) on every render, or to
keep a derived object reference stable.
const result = $memo(() => computeExpensiveValue(a, b), [a, b])
- The first argument is a thunk — a zero-argument function that returns the value.
- The second is a dependency array. Dependencies are
compared shallowly with
Object.is; if none changed since the last render, the cached value is returned untouched. - Omit the array to recompute on every render
(
$memo(() => …)) — matching React.
For a derived value that should simply track whatever atoms it reads —
no dependency array to maintain — reach for
$util.derived(() => …) instead. Use $memo when you
want an explicit dependency list or to cache a genuinely expensive
computation. See Reactivity → Derived
values.
function Cart() {
const [qty, setQty] = $state(1)
// Recomputed only when `qty` changes — not on unrelated re-renders.
const total = $memo(() => qty * 19.99, [qty])
return Column([
Text(`Quantity: ${qty}`),
Button("Add one", { variant: "primary", onClick: () => setQty(q => q + 1) }),
Text(`Total: ${$util.format(total, "currency")}`, { variant: "large-heavy", tone: "primary" }),
], { gap: "md" })
}
$app(Cart())
$ref — a value that survives renders without triggering one
$ref(initial) returns a stable { current } box,
just like React’s useRef. The same object is handed back
on every render, and writing ref.current never
schedules a re-render. Reach for it to remember a value across
renders that isn’t part of the UI — a previous value, a timer
id, a scroll position, or an imperative handle from
Mount / OnMount.
const box = $ref(initialValue)
box.current // read the stored value
box.current = next // store a new value — no re-render
Here a $ref remembers the previous count while
$state drives the visible one — the classic
“previous value” pattern:
function Tracker() {
const [count, setCount] = $state(0)
const prev = $ref(0)
const previous = prev.current // value from the last render
prev.current = count // remember the current one for next time
return Column([
Text(`Now: ${count} · Previously: ${previous}`, { variant: "large-heavy" }),
Button("Increment", { variant: "primary", icon: "plus", onClick: () => setCount(c => c + 1) }),
], { gap: "md" })
}
$app(Tracker())
Because a $ref write is invisible to the renderer, never
read ref.current to drive what the UI shows — the
screen won’t update. Use $state for anything the user
should see, and $ref for the bookkeeping beside it.
$reducer — state transitions in one place
$reducer((state, action) => next, initial) returns a
[state, dispatch] pair — React’s
useReducer. Instead of scattering setters, you describe every
transition in a single pure reducer and trigger them with
dispatch(action). It shines when the next state depends on the
current one or when several actions update the same shape.
const [state, dispatch] = $reducer((state, action) => {
// return the next state for each action
}, initialState)
function Counter() {
const [state, dispatch] = $reducer((s, action) => {
if (action === "inc") return { count: s.count + 1 }
if (action === "dec") return { count: s.count - 1 }
if (action === "reset") return { count: 0 }
return s
}, { count: 0 })
return Column([
Text(`Count: ${state.count}`, { variant: "large-heavy" }),
Row([
Button("Decrement", { icon: "minus", onClick: () => dispatch("dec") }),
Button("Reset", { onClick: () => dispatch("reset") }),
Button("Increment", { variant: "primary", icon: "plus", onClick: () => dispatch("inc") }),
], { gap: "sm" }),
], { gap: "md" })
}
$app(Counter())
The action can be any value — a string like "inc", or an
object such as { type: "add", text } when a transition needs a
payload. dispatch is reference-stable across renders, so it is
safe to hand straight to an onClick without re-creating it.
$id — stable unique identifiers
$id(prefix?) returns a string id that is generated once per
component instance and stays the same across renders — React’s
useId. Use it to link a label to a control, wire
aria-describedby, or key a fragment, without risking
collisions when the component renders more than once.
function Field() {
const id = $id("email")
return Column([
Text("Email address", { variant: "small-heavy" }),
Input(id, { placeholder: "you@example.com" }),
Text(`This instance owns the stable id "${id}".`, { variant: "small", tone: "muted" }),
], { gap: "xs" })
}
// Two instances → two distinct, stable ids.
$app(Column([Field(), Field()], { gap: "lg" }))
Custom hooks
Declare a custom hook with function $name(…) —
the leading $ on the function name is the marker. A custom
hook’s body runs inline in the calling component’s
scope, so the $state / $memo calls
inside it allocate slots on the component that called it. That is exactly
how a React custom hook shares its caller’s state — it lets
you bottle up a piece of stateful behaviour and reuse it.
A hook returns whatever you like — commonly an object of values and
updater functions. Invoke it as $name(…).
// A reusable "toggle" hook built from $state.
function $useToggle(initial) {
const [on, setOn] = $state(initial)
return { on: on, toggle: () => setOn(v => !v) }
}
function Panel() {
const details = $useToggle(false)
return Column([
Button(details.on ? "Hide details" : "Show details", { onClick: details.toggle }),
details.on ? Card([Text("Hooks compose just like they do in React.")]) : null,
], { gap: "md" })
}
$app(Panel())
Hooks compose freely — a custom hook can call $state,
$memo, and other custom hooks. Here $useCounter
bundles a counter’s state, a derived value, and its actions behind
one call:
function $useCounter(start) {
const [count, setCount] = $state(start)
const isEven = $memo(() => count % 2 === 0, [count])
return {
count: count,
isEven: isEven,
increment: () => setCount(c => c + 1),
reset: () => setCount(start),
}
}
function Demo() {
const counter = $useCounter(0)
return Column([
Text(`Count: ${counter.count} (${counter.isEven ? "even" : "odd"})`, { variant: "large-heavy" }),
Row([
Button("Increment", { variant: "primary", onClick: counter.increment }),
Button("Reset", { onClick: counter.reset }),
], { gap: "sm" }),
], { gap: "md" })
}
$app(Demo())
Rules of hooks
Hooks are matched to their state slots by call order, so that order must be the same on every render. The same two rules as React:
| Rule | Why |
|---|---|
| Call hooks only at the top level of a component or another hook. | A hook inside an if, loop, or callback would run a different number of times across renders, shifting every later slot. Branch on the result of a hook, not around the call. |
| Call hooks only from a component (a function used in render position) or another hook. | Hooks need an active instance to attach their slots to. Calling $state from a plain action or at the program top level has no instance — it falls back to an inert value and logs a warning. |
Lifecycle & reset on unmount
Hook state lives for as long as its component instance is on screen. When the instance leaves the render tree, its hook state is dropped — a future remount starts again from the initial value. This is React’s reset-on-unmount behaviour. Toggle a component in and out of the tree (e.g. behind a tab or a condition) and it comes back fresh; keep it mounted and its state persists across renders.
Because each instance owns its slots, a key on the call site
pins that identity when siblings reorder — the same mechanism that
keeps list items attached to the right state.
Hooks vs. $name = value
Aktion has two ways to hold component-local state. They share the same reactive store; pick whichever reads best.
$state hook | $name = value declaration | |
|---|---|---|
| Shape | const [v, setV] = $state(0) — explicit value + setter. |
$count = 0 at the top of a component body — a reactive atom read and written by name. |
| Update | setV(next) or setV(prev => next). |
$count = $count + 1 (and +=, ++, member writes like $user.name = …). |
| Reach for it when | A component owns local state with explicit setters, or you want to package the state + its updaters into a reusable hook. | An atom is read and mutated directly by the component’s own actions — the lighter-weight option. |
| Composable into a custom hook | Yes — this is what custom hooks are built from. | No — it is a declaration, not a value you can return. |
Both seed once and persist across re-renders, regardless of whether the
component name is PascalCase or lowercase.
Next
Components
Declare components, pass props and slots, and compose the built-in component library.
Open the guide → BehaviourSide effects
The $effect(() => { … }, [deps]) call for clocks, polling, listeners, and cleanup.
Language
Reactive state, fine-grained dependency tracking, expressions, and control flow.
View the reference →