Targ Apps Docs
Chroma

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)

ArgTypeRequiredDescription
tagstring | FunctionYesAn HTML tag name, or a component function.
propsobject | nullYes (may be null)Attributes, events, and special props.
childrenany, variadicNoSee 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 shapeResult
on* + function (onClick, onInput)addEventListener("click" / "input" / ...)
ref (object)Sets ref.current to the element
ref (function)Called with the element
Any other functionReactive attribute, re-evaluated when its signals change
false, null, undefinedAttribute omitted, or removed on update
Anything elseStatic 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:

ChildResult
DOM NodeAppended as is
FunctionReactive child: mounted in a display: contents slot and re-evaluated when its signals change; may return text or DOM nodes
ArrayFlattened recursively
null, undefined, booleanRenders nothing
String, number, otherText 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)

ArgTypeRequiredDescription
componentFunctionYesRoot component, called once with an empty props object.
containerElementYesWhere 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].

MemberDescription
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 fn returns 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)

PropTypeRequiredDescription
when() => anyYesGetter evaluated reactively.
fallback() => nodeNoContent shown while when() is falsy. Omitted means nothing is shown.
children() => node or a nodeYesContent 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 object
  • chroma: 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 })

PropTypeRequiredDescription
each() => ArrayYesGetter returning the array. A non-array result renders nothing.
children(item) => nodeYesRender 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 object
  • chroma: 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 keyTypeRequiredDescription
basestring | string[]NoAlways included.
variants{ group: { option: class } }NoClasses per option.
compoundVariantsArray<{ ...group: option, class }>NoAdded when every named group matches the selection.
defaultVariants{ group: option }NoUsed for groups missing from props.

Errors:

  • chroma: cva expects a config object
  • chroma: cva expects config.compoundVariants to be an array
  • chroma: cva expects every compoundVariants entry to be an object
  • chroma: 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?)

ArgTypeRequiredDescription
routesArray<Route>YesFirst matching route wins.
options.mode"hash" | "history"NoDefault "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:

MemberDescription
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.
ViewComponent: 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");           // null

parseQuery

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" }

Next

On this page