Core concept

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:

$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)
PartMeaning
valueThe 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).
initialValueEvaluated 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.

Live
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])

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.

Live
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:

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

Live
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(…).

Live
// 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:

Live
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:

RuleWhy
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