Targ Apps Docs
Chroma

Hooks Guide

Every Chroma hook in depth — dependency-array semantics and how to write custom hooks.

Chroma ships a React-compatible hook surface adapted to fine-grained reactivity. The API names are familiar, but the mental model is different in one crucial way: components run once, so there is no such thing as "the next render".

Dependency arrays

This is the key difference from React/Preact:

The dependency array carries signal getters, not values.

useEffect(() => { ... }, [count]);    // ✅ getter — subscribes to the signal
useEffect(() => { ... }, [count()]);  // ❌ value — a number can't be subscribed to

Why: without re-renders there is no "compare deps against the previous render". A dependency in Chroma is a subscription to a signal. Passing the getter lets the runtime subscribe; passing the value gives it a dead number that never changes again.

Three forms:

DepsBehavior
omittedAuto-tracking (SolidJS style): every signal read inside the function subscribes automatically
[getterA, getterB]Explicit: subscribes to exactly those signals; the body itself runs untracked
[]Runs once, never re-runs

With an explicit array, the effect/memo body runs with tracking suppressed — like React, where the body does not define the dependencies, the array does. This is implemented by the shared reactive_expr(f, deps) helper: it wraps f in a function that calls every getter in deps (subscribing), then runs f itself inside untracked.

hookState

const [count, setCount] = hookState(0);

Chroma's state primitive, backed by a signal in the Rust runtime. Note the name: it's hookState, not useState — a deliberate naming choice to keep it distinct from the rest of the (React-cased) hook surface.

  • count() — reads the value. Inside a reactive expression it registers a dependency; outside, it's just a read.
  • setCount(next) — writes. Bindings re-run synchronously, and only those subscribed to this signal.
  • setCount(prev => next) — updater form: called with the current value if the argument is a function.
  • Writes that don't change the value (Object.is) are no-ops — nothing re-runs.

useEffect and useLayoutEffect

Both run a side effect tied to signal changes; both support cleanup by returning a function. They differ only in when the first run happens:

First runRe-runs
useEffectDeferred to a microtask, after mountingSynchronous on dependency change
useLayoutEffectSynchronous, right after render mounts, before paintSynchronous on dependency change

Use useLayoutEffect when you need the DOM (measure an element, focus an input via ref) before the user sees the frame.

useEffect(() => {
    const id = setInterval(tick, 1000);
    return () => clearInterval(id);   // cleanup: runs before every re-run
}, []);

useMemo

const doubled = useMemo(() => count() * 2, [count]);

Returns a getter, not a value — this is the natural shape in a world without re-renders. The computation is a binding whose result is written into an internal signal; the returned getter reads that signal. Consequences:

  • Reading doubled() inside a reactive expression subscribes to the memo's signal, giving you a fine-grained derived chain.
  • The computation re-runs only when its dependencies change — never on read.

useCallback

Pure identity — hook_callback(callback, _deps) returns callback unchanged. Since components run once, functions are created once; there is nothing to memoize. Kept so React-shaped code ports cleanly.

useRef

const box = useRef(null);
e("div", { ref: box });

Plain { current } object, no reactivity. The ref prop of e accepts either a useRef object (sets .current) or a callback (called with the node).

useReducer

const [state, dispatch] = useReducer(reducer, initialState);

[getter, dispatch] over a signal. dispatch(action) reads the current value with peek_signal (untracked), computes reducer(current, action), and writes the result. Reducer errors are caught and logged to the console rather than breaking the app — the state is left unchanged.

createContext / useContext

const Theme = createContext("light");

function App() {
    return e(Theme.Provider, { value: "dark" },
        () => e(Toolbar)          // ← children as a function!
    );
}

function Toolbar() {
    const theme = useContext(Theme);   // "dark"
    ...
}

Why children must be a function: there is no compiler making children lazy. In a plain call e(Provider, props, e(Child)), e(Child) would evaluate before the Provider runs — outside its context. Wrapping children in a function defers evaluation until the Provider has pushed its value onto the context stack.

Providers nest as expected: useContext sees the nearest active Provider (a per-context stack, so nested Providers of the same context shadow correctly), falling back to defaultValue when none is active.

useDebugValue

globalThis.__CHROMA_DEBUG__ = true;   // enable
useDebugValue(value, v => `state: ${v}`);

Console-based debug labels (Chroma has no DevTools). Silent unless the global flag is truthy; when enabled, it logs via console.debug, applying the optional formatter to value first.

Writing custom hooks

Custom hooks are just functions composing the primitives — no rules of hooks, no call-order constraints, because nothing re-runs:

function useCounter(initial) {
    const [count, setCount] = hookState(initial);
    const doubled = useMemo(() => count() * 2, [count]);
    const inc = useCallback(() => setCount(count() + 1));
    useDebugValue("useCounter");
    return { count, doubled, inc };
}

You can call hooks conditionally, in loops, or inside event handlers — every call simply allocates a new signal/binding in the runtime, since there is no per-render hook index to keep in sync.

Where to go next

On this page