Templates and state
Declare state on the document, read it with $-directives, change it with actions.
A template is a Spec where any value may be a directive: an object with exactly one $-key, like { "$get": "/user/name" }. <Joy> resolves the template against the document's state on every render. The render below never sees a directive.
import { } from "@joy-dom/react";
import type { } from "@joy-dom/core";
const : = {
: 1,
: { : { : "Ada" }, : [{ : "Tee" }] },
: { ".cart": { : "flex" } },
: {
: "div",
: { : ["cart"] },
: [
{ : "h1", : [{ : "/user/name" }] },
{
: { : "/cart" },
: "line",
: { : "p", : [{ : "/line/name" }] },
},
],
},
};
export function () {
return < ={} />;
}The document owns its state:
- The
stateregion seeds it. TheinitialStateprop overrides that seed. - Actions are the only writers.
- The host reads and writes through the handle its
onEventlistener receives.
The $-key is the directive's marker, and its value is the primary argument. Options (as, do, else, …) sit next to the marker as sibling keys. An option the directive doesn't declare throws, so typos fail loudly.
Built-in directives
| Directive | Options | Result |
|---|---|---|
$get | none | The value at a JSON Pointer, e.g. /user/name. Missing data reads as null. |
$if | do, else | Resolves do when the condition is true, else when it is false or null. Any other type throws notABoolean — there is no truthiness coercion. Without else, null. |
$for | as, index, do | Resolves do once per array item. Bindings default to item and index. |
In a node position, null contributes nothing and arrays splice in flat. So $if without else drops the node, and $for emits one node per item.
Where names come from
A $get pointer's first segment is a bare name. Lookup checks three scopes, and the first match wins:
| Precedence | Scope | Source |
|---|---|---|
| 1 | Loop bindings | The as and index names from the enclosing $for. |
| 2 | defs | Named definitions on the template. Lazy and memoized: a def resolves the first time it is read. |
| 3 | state | The document's live state, seeded from state (or initialState), then changed by mutation actions. |
state and defs are consumed by the resolve pass, so the resolved document doesn't carry them. validate(view) from @joy-dom/core reports two authoring-time problems: name collisions across the scopes (duplicateName) and use of the reserved names state and defs (reservedName).
Error behavior
Missing data degrades. An unmatched pointer reads as null, and the template renders without that value. Anything else wrong throws a single ResolveError carrying identity (what went wrong, e.g. unknownDirective, notAList) and marker (which directive).
Custom directives
The directives prop replaces the default registry. Extend the default pack with register. Every call returns a new registry, so share them freely.
const : Directive = {
: (, ) => .(.(.)).(),
};
const = ..("$upper", );
export function () {
return < ={} ={{ : "ada" }} ={} />;
}A directive's body receives args (the primary argument plus typed option readers) and ctx (resolve, resolveValue, lookup), and returns one JSON value. override swaps a built-in. merge combines two registries with an explicit conflict policy.
Resolving without React
resolve from @joy-dom/core is the same pass <Joy> runs. Call it directly in a server function or a test:
const = (, { : { : "Ada" } });It returns a plain Spec, so the output renders on any platform, not only React.
Where next
- API reference documents the
initialStateanddirectivesprops in full. - Events and actions changes state with mutations and reaches the host with events.