Joy DOM

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 state region seeds it. The initialState prop overrides that seed.
  • Actions are the only writers.
  • The host reads and writes through the handle its onEvent listener 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

DirectiveOptionsResult
$getnoneThe value at a JSON Pointer, e.g. /user/name. Missing data reads as null.
$ifdo, elseResolves 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.
$foras, index, doResolves 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:

PrecedenceScopeSource
1Loop bindingsThe as and index names from the enclosing $for.
2defsNamed definitions on the template. Lazy and memoized: a def resolves the first time it is read.
3stateThe 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

On this page