API reference
Signature, props, errors, and a working example for every function the Chroma engine exports.
Every export of the engine, with its signature, errors, and one example. For the mental model behind these functions, start with Reactivity and Hooks.
Import
Examples assume a file under src/, so the engine is one level up in pkg/:
// src/App.chr
import {
e, Fragment, render,
hookState, useReducer,
useEffect, useLayoutEffect, useMemo, useCallback,
useRef, createContext, useContext, useDebugValue,
Show, For,
cva, cx,
createRouter, matchRoute, parseQuery,
} from "../pkg/chroma.js";Importing the module instantiates the WebAssembly engine automatically (chroma.js awaits its own init), so there is no init() call to make. The package also exports start (runs on load) and the wasm-bindgen initSync and default init helpers; apps do not call them.
Hooks reuse React names but not React semantics: dependencies are getters ([count], not [count()]), components run once, and there are no rules of hooks.
Core
e
Creates a node.
Import: import { e } from "../pkg/chroma.js";
Signature: e(tag, props, ...children)
| Arg | Type | Required | Description |
|---|---|---|---|
tag | string | Function | Yes | An HTML tag name, or a component function. |
props | object | null | Yes (may be null) | Attributes, events, and special props. |
children | any, variadic | No | See the child table below. |
Returns: a DOM Element for a string tag, or whatever the component function returns.
Errors: chroma: e() expects a string tag or a component function when tag is neither.
Behavior, string tag. Each prop is applied to the element:
| Prop shape | Result |
|---|---|
on* + function (onClick, onInput) | addEventListener("click" / "input" / ...) |
ref (object) | Sets ref.current to the element |
ref (function) | Called with the element |
| Any other function | Reactive attribute, re-evaluated when its signals change |
false, null, undefined | Attribute omitted, or removed on update |
| Anything else | Static attribute, set once |
Use class for CSS classes. className is accepted as an alias for class, and htmlFor for for.
Behavior, component function. The function is called exactly once with props. Children passed as extra arguments are merged into props.children: a single child stays unwrapped, several become an array. If you pass no children, props.children is whatever props already contained.
Behavior, children:
| Child | Result |
|---|---|
DOM Node | Appended as is |
| Function | Reactive child: mounted in a display: contents slot and re-evaluated when its signals change; may return text or DOM nodes |
| Array | Flattened recursively |
null, undefined, boolean | Renders nothing |
| String, number, other | Text node (objects go through JSON.stringify) |
// src/components/Greeting.chr
import { e } from "../../pkg/chroma.js";
export function Greeting(props) {
return e("p", { class: "greeting" }, "Hello, ", props.name);
}Fragment
Groups children without a wrapper element.
Signature: e(Fragment, null, ...children)
Returns: a DocumentFragment. Children follow the same rules as e.
// src/components/Header.chr
import { e, Fragment } from "../../pkg/chroma.js";
export function Header() {
return e(Fragment, null, e("h1", null, "Title"), e("p", null, "Body"));
}render
Mounts a root component.
Signature: render(component, container)
| Arg | Type | Required | Description |
|---|---|---|---|
component | Function | Yes | Root component, called once with an empty props object. |
container | Element | Yes | Where the returned node is appended. |
Errors:
chroma: render() requires an Element as the container (did the selector return null?)chroma: the root component did not return a DOM node
Behavior: after the tree is mounted, queued layout effects run synchronously, before the browser paints.
// src/main.chr
import { render } from "../pkg/chroma.js";
import { App } from "./App.chr";
render(App, document.getElementById("app"));State
hookState
Primary state hook.
Signature: hookState(initial) returns [getter, setter].
| Member | Description |
|---|---|
getter() | Returns the current value. Inside a reactive expression (child, attribute, effect, memo) it registers a dependency. |
setter(value) | Writes a value. Bindings that depend on the signal re-run only if the new value is not Object.is-equal to the old one. |
setter(fn) | Updater form: fn receives the current value and returns the next. |
// src/components/Counter.chr
import { e, hookState } from "../../pkg/chroma.js";
export function Counter() {
const [count, setCount] = hookState(0);
return e("button", { onClick: () => setCount((c) => c + 1) }, "Count: ", count);
}useReducer
Reducer-driven state.
Signature: useReducer(reducer, initial) returns [getter, dispatch].
Behavior: dispatch(action) computes reducer(current, action) and writes the result with the same change detection as hookState. If reducer throws, the error is logged to the console and the state is left unchanged.
// src/components/Clicks.chr
import { e, useReducer } from "../../pkg/chroma.js";
export function Clicks() {
const [state, dispatch] = useReducer((s, action) => {
return action.type === "inc" ? { n: s.n + 1 } : s;
}, { n: 0 });
return e("button", { onClick: () => dispatch({ type: "inc" }) }, () => `Clicks: ${state().n}`);
}Effects
useEffect
Reactive side effect.
Signature: useEffect(fn, deps?) where deps is an array of getters.
Behavior:
- The first run is deferred to a microtask, after the component mounts.
- Re-runs are synchronous whenever a dependency changes.
- If
fnreturns a function, it runs as cleanup before each re-run. - A cleanup that throws is logged to the console (
chroma: error in effect cleanup:) and does not stop the update.
See Hooks for dependency-array semantics.
// src/components/TitleSync.chr
import { e, hookState, useEffect } from "../../pkg/chroma.js";
export function TitleSync() {
const [count, setCount] = hookState(0);
useEffect(() => {
document.title = `count: ${count()}`;
return () => console.log("cleanup");
}, [count]);
return e("button", { onClick: () => setCount(count() + 1) }, "Add");
}useLayoutEffect
Same signature and behavior as useEffect, except the first run is synchronous: it fires when render finishes mounting the tree, before paint. The DOM and refs are available.
// src/components/FocusInput.chr
import { e, useRef, useLayoutEffect } from "../../pkg/chroma.js";
export function FocusInput() {
const input = useRef(null);
useLayoutEffect(() => input.current.focus(), []);
return e("input", { ref: input });
}Derived values
useMemo
Cached derived value.
Signature: useMemo(calc, deps?) returns a reactive getter.
Behavior: the computation re-runs only when its dependencies change, not on every read.
// src/components/Doubled.chr
import { e, hookState, useMemo } from "../../pkg/chroma.js";
export function Doubled() {
const [count, setCount] = hookState(1);
const doubled = useMemo(() => count() * 2, [count]);
return e("button", { onClick: () => setCount(count() + 1) }, "Doubled: ", doubled);
}useCallback
Signature: useCallback(fn, deps?) returns fn.
Behavior: identity function. Components run once, so functions are never recreated. It exists only so React-style code ports without edits.
Refs
useRef
Non-reactive mutable reference.
Signature: useRef(initial) returns { current: initial }.
Behavior: writing current notifies nobody. Pass the object as the ref prop of an element to receive the DOM node (see useLayoutEffect above).
Context
createContext
Signature: createContext(defaultValue) returns { Provider, defaultValue }.
Behavior: children are evaluated before their parent, so Provider takes its children as a function and evaluates them with the context active.
// src/theme.chr
import { e, createContext, useContext } from "../pkg/chroma.js";
export const Theme = createContext("light");
export function ThemedLabel() {
const theme = useContext(Theme);
return e("p", null, `theme: ${theme}`);
}
export function Page() {
return e(Theme.Provider, { value: "dark" }, () => e(ThemedLabel));
}useContext
Signature: useContext(ctx) returns the value of the nearest active Provider, or defaultValue if there is none.
Errors: chroma: useContext expects a context created with createContext.
Debugging
useDebugValue
Signature: useDebugValue(value, formatter?)
Behavior: logs with console.debug only when globalThis.__CHROMA_DEBUG__ is truthy. If formatter is given, it is applied to value first.
// src/main.chr
import { useDebugValue } from "../pkg/chroma.js";
globalThis.__CHROMA_DEBUG__ = true;
useDebugValue("boot");Control flow
Concepts and pitfalls live in Control flow. This section is the contract.
Show
Conditional rendering.
Import: import { e, Show } from "../pkg/chroma.js";
Signature: e(Show, { when, fallback }, children)
| Prop | Type | Required | Description |
|---|---|---|---|
when | () => any | Yes | Getter evaluated reactively. |
fallback | () => node | No | Content shown while when() is falsy. Omitted means nothing is shown. |
children | () => node or a node | Yes | Content shown while when() is truthy. |
Children conventions. Both forms reach the same props.children:
- Third argument:
e(Show, { when }, () => e(Panel)) - Prop:
e(Show, { when, children: () => e(Panel) })
A function is called once under untracked. A non-function child is mounted once as is.
Errors:
chroma: Show expects a props objectchroma: Show requires props.when to be a getter (function)
Behavior: both branches are built once at mount, each in its own <div> slot inside a display: contents wrapper. Changes to when() only flip each slot between display: contents and display: none. Consequences: hooks inside both branches run at mount, and branch content is never re-created. when follows JavaScript truthiness: null, undefined, false, 0, -0, NaN, and "" are falsy; every other value is truthy.
// src/components/Gate.chr
import { e, Show, hookState } from "../../pkg/chroma.js";
export function Gate() {
const [loggedIn, setLoggedIn] = hookState(false);
const toggle = () => setLoggedIn(!loggedIn());
return e(
"section",
null,
e("button", { onClick: toggle }, "Toggle"),
e(
Show,
{ when: loggedIn, fallback: () => e("p", null, "Please log in") },
() => e("p", null, "Welcome back"),
),
);
}For
Reactive list.
Import: import { e, For } from "../pkg/chroma.js";
Signature: e(For, { each, children })
| Prop | Type | Required | Description |
|---|---|---|---|
each | () => Array | Yes | Getter returning the array. A non-array result renders nothing. |
children | (item) => node | Yes | Render function called once per item. |
Children conventions. Both forms work, and the value must be a function:
- Prop:
e(For, { each: todos, children: (item) => e("li", null, item.text) }) - Third argument:
e(For, { each: todos }, (item) => e("li", null, item.text))
Errors:
chroma: For expects a props objectchroma: For requires props.each to be a getter (function)chroma: For requires props.children to be a function: (item) => node
Behavior: there is no key prop. Whenever the array read by each changes, all list nodes are cleared and rebuilt. Only the each getter is tracked; the render function runs untracked. The array must change by reference (setItems([...items(), x])) to notify.
// src/components/TodoList.chr
import { e, For, hookState } from "../../pkg/chroma.js";
export function TodoList() {
const [todos, setTodos] = hookState([{ id: 1, text: "Write docs" }]);
const add = () => setTodos([...todos(), { id: todos().length + 1, text: "Ship it" }]);
return e(
"section",
null,
e("button", { onClick: add }, "Add"),
e("ul", null, e(For, {
each: () => todos(),
children: (todo) => e("li", null, todo.text),
})),
);
}Class helpers
cx
Merges class names.
Signature: cx(...parts) returns string.
Behavior: skips null, undefined, false, and empty strings; flattens arrays; joins the rest with one space. Numbers and true are ignored too, so use cond && "name".
// src/components/Tab.chr
import { e, cx } from "../../pkg/chroma.js";
export function Tab(props) {
return e("button", { class: cx("tab", props.active && "tab-active", ["a", "b"]) }, props.children);
}cva
Defines a reusable class resolver.
Signature: cva(config) returns (props) => string.
| Config key | Type | Required | Description |
|---|---|---|---|
base | string | string[] | No | Always included. |
variants | { group: { option: class } } | No | Classes per option. |
compoundVariants | Array<{ ...group: option, class }> | No | Added when every named group matches the selection. |
defaultVariants | { group: option } | No | Used for groups missing from props. |
Errors:
chroma: cva expects a config objectchroma: cva expects config.compoundVariants to be an arraychroma: cva expects every compoundVariants entry to be an objectchroma: cva expects every compoundVariants entry to have a string `class`
Behavior: the returned function resolves, in order, base, the selected option of each variant group (boolean props match the options "true" and "false"), matching compoundVariants, then props.class and props.className verbatim.
// src/components/Button.chr
import { e, cva } from "../../pkg/chroma.js";
const buttonClass = cva({
base: "btn",
variants: {
variant: { primary: "btn-primary", ghost: "btn-ghost" },
size: { sm: "btn-sm", lg: "btn-lg" },
},
compoundVariants: [{ variant: "primary", size: "lg", class: "btn-primary-lg" }],
defaultVariants: { variant: "primary", size: "sm" },
});
export function Button(props) {
return e("button", { class: buttonClass(props), onClick: props.onClick }, props.children);
}Screen routing
These three functions belong to the WASM engine and drive screens inside the page. They are not the @chroma/router plugin, which has its own createRouter for file-based API handlers. See Routing for the difference.
createRouter
Signature: createRouter(routes, options?)
| Arg | Type | Required | Description |
|---|---|---|---|
routes | Array<Route> | Yes | First matching route wins. |
options.mode | "hash" | "history" | No | Default "hash" (#/path?query). "history" needs a host that rewrites unknown paths to index.html; chroma dev does not. |
A route is { path, component, GET, POST, PUT, DELETE, CUSTOM }; only path is required. Patterns use :name for one segment and *name for the rest of the path.
Errors: chroma: createRouter expects an array of routes.
Returns: an object with:
| Member | Description |
|---|---|
navigate(path, { replace? }) | Updates the URL and the current match. A non-string path logs chroma: router.navigate expects a string path. |
back() | Calls history.back(). |
current() | Reactive getter returning { route, path, params, query } or null. Call it inside an effect, attribute, or For to follow navigation. |
match(path) | Same shape as current() for any path, without subscribing. |
request(req) | Verb dispatch. req is { method, path, query?, body?, op? }. GET, POST, PUT, DELETE call route.METHOD(req); any other method calls route.CUSTOM(op, req). Returns the handler's value as is (a Promise only if the handler returns one), or { ok: false, status: 404 } with no matching route, or { ok: false, status: 405 } when the route lacks that verb. |
View | Component: e(router.View, {}) mounts the matched route's component and re-renders when the location changes. The component receives { params, query, path }. |
// src/router.chr
import { e, createRouter } from "../pkg/chroma.js";
export const router = createRouter([
{ path: "/", component: () => e("h1", null, "Home") },
{ path: "/items/:id", component: (props) => e("p", null, "Item " + props.params.id) },
], { mode: "hash" });
// Mount it with: render(() => e(router.View, {}), document.getElementById("app"));
// router.navigate("/items/42");matchRoute
Signature: matchRoute(pattern, path) returns { ...params } or null.
Behavior: the same matching createRouter uses. Query string and hash on path are ignored, and the segment count must match unless the pattern ends in a *wildcard.
// src/lib/match.chr
import { matchRoute } from "../../pkg/chroma.js";
matchRoute("/items/:id", "/items/42?tab=files"); // { id: "42" }
matchRoute("/files/*rest", "/files/a/b.txt"); // { rest: "a/b.txt" }
matchRoute("/items/:id", "/other/1"); // nullparseQuery
Signature: parseQuery(input) returns a flat object of strings.
Behavior: accepts "?a=1&b=2" or "a=1&b=2", decodes %XX and +, and returns {} for an empty string. A repeated key keeps its last value.
// src/lib/query.chr
import { parseQuery } from "../../pkg/chroma.js";
parseQuery("?name=Targ%20Apps&tag=a+b"); // { name: "Targ Apps", tag: "a b" }