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 toWhy: 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:
| Deps | Behavior |
|---|---|
| omitted | Auto-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 run | Re-runs | |
|---|---|---|
useEffect | Deferred to a microtask, after mounting | Synchronous on dependency change |
useLayoutEffect | Synchronous, right after render mounts, before paint | Synchronous 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
- API Reference — exact signatures for every hook
- Reactivity Model — the tracking cycle these hooks are built on
- Examples — every hook composed together in a runnable app