Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/sanchedev/fraxel/llms.txt

Use this file to discover all available pages before exploring further.

Reactivity is the heart of Fraxel’s developer experience. Rather than re-rendering a Virtual DOM tree, Fraxel uses signals — tiny reactive primitives that hold a value and notify subscribers when it changes. When a signal changes, only the exact canvas property bound to it is updated. No diffing, no tree traversal, no frame budget wasted on unchanged nodes. The result is smooth 60 FPS updates driven entirely by direct, surgical property mutations.

Signals

A signal is an instance of the Signal<T> class. It holds a value, tracks subscribers, and notifies them synchronously whenever the value is set to something new.
import { Signal } from 'fraxel'

const health = new Signal(100)

health.sub((val) => {
  console.log('Health changed to:', val)
})

health.value = 50 // logs: "Health changed to: 50"
In components, you create signals through useSignal(initialValue), which wraps the Signal class and ties cleanup to the component’s node lifecycle:
import { useSignal } from 'fraxel/hooks'

function Player() {
  const [health, setHealth] = useSignal(100)

  // health is a SignalGetter — call it as a function to read
  console.log(health()) // 100

  setHealth(80) // update the signal
  console.log(health()) // 80

  return <transform />
}

The Getter is a Function

useSignal returns a tuple: [getter, setter]. The getter is a callable function — you must call it as health(), not reference it as health. This is what allows the reactivity system to automatically track which computations depend on which signals.
const [health, setHealth] = useSignal(100)

health()      // ✅ reads value and registers as dependency
health.value() // ✅ reads value WITHOUT registering as dependency
health        // ❌ this is the function reference, not the value
getter.value() bypasses dependency tracking. Use it when you want to read a signal inside a computed or effect without creating a reactive dependency.

Auto-Computed JSX Props

Any JSX prop that accepts a Reactive<T> type can receive either a static value or a function. When you pass a function (including an arrow function or a signal getter), Fraxel automatically tracks which signals are accessed inside it and re-applies the prop whenever any of those signals change. Static prop — set once at mount, never updates:
<sprite textureId={PLAYER} brightness={1.2} />
Reactive prop — updates whenever health signal changes:
const [health, setHealth] = useSignal(100)

<sprite
  textureId={PLAYER}
  grayscale={() => health() <= 0 ? 1 : 0}
  opacity={() => health() / 100}
/>
When setHealth(0) is called, only the grayscale and opacity properties on that specific Sprite node are updated. Nothing else in the tree is touched. This pattern works for any prop typed as Reactive<T>:
const [score, setScore] = useSignal(0)

// Text re-renders its string automatically when score changes
<text text={() => `Score: ${score()}`} />

// Rectangle re-sizes when the signal changes
<rectangle size={() => [score() * 2, 16]} fillColor={[0, 1, 0, 1]} />

// Timer re-reads its duration reactively
<timer duration={() => difficulty() === 'hard' ? 5 : 10} autoPlay />
Arrow functions in JSX props are cheap — they’re only evaluated when a dependency changes, not every frame. The engine subscribes to each signal once and unsubscribes on destroy.

Computed Values

useComputed(fn) creates a derived signal — a read-only signal whose value is automatically recomputed whenever any signal accessed inside fn changes. It returns a SignalGetter<T> that can itself be passed as a reactive JSX prop.
import { useSignal, useComputed } from 'fraxel/hooks'

function CooldownBar() {
  const [time, setTime] = useSignal(0)
  const progress = useComputed(() => time() / 3) // 3-second cooldown, 0.0–1.0

  return (
    <rectangle
      size={() => [progress() * 128, 8]}
      fillColor={() => [1 - progress(), progress(), 0, 1]}
    />
  )
}
Computed values chain automatically:
const [hp, setHp] = useSignal(100)
const isAlive = useComputed(() => hp() > 0)
const alpha   = useComputed(() => isAlive() ? 1 : 0)

// alpha updates whenever hp changes, via isAlive
<sprite opacity={alpha} />
useComputed cleans up all internal subscriptions when the node is destroyed, preventing memory leaks.

useEffect

useEffect(fn) runs a side-effect function on mount and re-runs it whenever any signal accessed inside fn changes. Unlike reactive props — which only update a single node property — useEffect can run arbitrary code in response to state changes.
import { useSignal, useEffect } from 'fraxel/hooks'

function Enemy() {
  const [health, setHealth] = useSignal(100)

  useEffect(() => {
    if (health() <= 0) {
      console.log('Enemy died!')
      // perform destruction, spawn effects, etc.
    }
    return () => {
      // optional cleanup before next run or on destroy
    }
  })

  return <transform />
}

Batching

If multiple signals change synchronously, useEffect runs only once — after all changes are applied. Re-executions are deferred via queueMicrotask and run before the next animation frame.
const [x, setX] = useSignal(0)
const [y, setY] = useSignal(0)
const [z, setZ] = useSignal(0)

useEffect(() => {
  console.log(x(), y(), z())
})

// Three setters — effect runs exactly once, seeing all three new values
setX(1)
setY(2)
setZ(3)
// queueMicrotask fires → effect runs once with x=1, y=2, z=3
Batching ensures the canvas always sees a consistent game state. You can safely update multiple signals in a single event handler, physics step, or timer callback without triggering redundant work.

Signal Class API

For advanced cases you can use Signal<T> directly — useful for signals that live outside a component, or when you need manual subscription control.
import { Signal } from 'fraxel'

const score = new Signal(0)

// Read the value
console.log(score.value) // 0

// Write the value (notifies all subscribers)
score.value = 10

// Subscribe to changes
const unsub = (val: number) => console.log('Score:', val)
score.sub(unsub)

score.value = 25 // logs: "Score: 25"

// Unsubscribe a specific listener
score.unsub(unsub)

// Remove all subscribers (useful for manual cleanup)
score.clearSubs()
Signal API reference
MemberType / SignatureDescription
value (get)TGet the current value
value (set)(val: T) => voidSet the value and notify all subscribers (skips if unchanged)
getterSignalGetter<T>Callable getter that registers the signal as a dependency when called
setterSignalSetter<T>Callable setter equivalent to signal.value = val
sub(fn)(fn: (val: T) => void) => voidSubscribe a listener
unsub(fn)(fn: (val: T) => void) => voidRemove a specific listener
clearSubs()() => voidRemove all listeners
Signal is identity-safe — setting the same value twice does not trigger subscribers:
const flag = new Signal(true)
flag.value = true  // no notification — value unchanged
flag.value = false // notification fires

Reactive vs Static Props

Use this decision guide when writing JSX props:
ScenarioUseExample
Value never changes after mountStatic valuebrightness={1.2}
Value changes based on game stateArrow function / SignalGetterbrightness={() => hp() > 50 ? 1 : 0.6}
Multiple props derive from the same signaluseComputedconst alpha = useComputed(() => hp() / 100)
Need to run code (not just update a prop) on changeuseEffectLogging, spawning, scene transitions
Need the latest value without tracking depsgetter.value()Inside a useEffect cleanup, or a plain callback
Example: static vs reactive in the same component
function HealthSprite() {
  const [hp, setHp] = useSignal(100)
  const gray   = useComputed(() => hp() <= 0 ? 1 : 0)
  const bright  = useComputed(() => 0.5 + hp() / 200) // 0.5–1.0

  return (
    <sprite
      textureId={PLAYER}         // static — never changes
      position={[100, 50]}       // static — set once
      grayscale={gray}           // reactive — updates when hp changes
      brightness={bright}        // reactive — updates when hp changes
      opacity={() => hp() / 100} // inline reactive arrow
    />
  )
}
Prefer useComputed over inline arrow functions for values shared across multiple props or used in multiple places — the computation runs once and the result is cached until a dependency changes.

How Dependency Tracking Works

When a SignalGetter is called inside a tracked context (a useEffect, useComputed, or a reactive JSX prop function), the SignalRegister records that signal as a dependency. After evaluation, the system subscribes the effect or computed to each discovered signal.
signal.getter() called
  → SignalRegister.register(signal)
  → signal is added to current watch context

Context finishes
  → all discovered signals are subscribed with a "re-run" callback
  → next time any signal.value changes → callback fires → context re-runs
This is why calling getter.value() instead of getter() skips tracking — it bypasses SignalRegister.register.
useEffect(() => {
  // tracked — effect re-runs when hp changes
  const current = hp()

  // NOT tracked — safe to read without creating a dependency
  const max = maxHp.value()

  console.log(`${current} / ${max}`)
})

Hooks

useSignal, useComputed, useEffect, and all other hooks

Nodes

Reactive props on every JSX node type

API: Hooks Core

Full TypeScript signatures for the reactive hooks

Concepts

Scene tree, lifecycle, and rendering architecture

Build docs developers (and LLMs) love