Reference

The current public contract.

Boot

html
<script src="ox.min.js" defer></script>

The document binds every [moo] element after it loads. ox.morph and SSE element patches already bind what they insert. Call ox.init(root?) only when you insert markup some other way (innerHTML, createElement, a stamped template) or for a ShadowRoot. root must be an Element or ShadowRoot.

Attribute grammar

The full form is { event: expression | { fn, …options } }. Event names are real DOM events. init and effect are reserved.

  • moo="body" — shortcut for { effect: "body" }. Used when the attribute does not start with {.

  • moo="{ … }" — handler object. A trimmed value that starts with { is always this object. Put a block effect in { effect: "…" }.

  • A string value is the expression body; same as { fn: "…" }.

  • init — runs once when bound.

  • effect — reruns when values it read change.

  • Other keys — DOM event names. morph is a valid name. Ox does not dispatch a morph event.

init and effect are the only reserved keys. Cleanup is only via a value returned from init, effect, or an event. See Lifecycle.

Expressions are exact JavaScript compiled with new Function.

  • el — the bound element.

  • st — shared reactive state; the same object as ox.state.

  • ox — the public API.

  • evt — the DOM event; event handlers only.

A missing or whitespace-only moo attribute unbinds the element. An invalid replacement is reported through reportError and leaves the previous valid binding working. There is no ox:error event.

Event handlers

Object handlers require fn. Lifecycle hooks (init, effect) only allow { fn }.

  • fn — JavaScript function body.

  • target"window" or "document"; defaults to el.

  • outside — listen on document; run only for events outside el.

  • prevent / stop — call preventDefault() / stopPropagation().

  • once / passive / capture — passed to addEventListener.

There is no debounce, throttle, or delay option. passive and prevent cannot be combined.

Lifecycle

Ox binds an element in this order:

  1. Compile the moo attribute

  2. Run the previous binding’s cleanups

  3. Run init

  4. Attach event listeners

  5. Start effect

If a morph leaves the moo text unchanged, Ox skips this sequence and keeps the live binding. An invalid replacement does not unbind: the previous binding stays, and reportError records the compile failure.

Returned disposers

init, effect, and event expressions may return a function, or an array of functions. Ox calls each function later. Other return values are ignored.

html
<output moo="{
  init: `
    const id = setInterval(() => st.tick++, 1000)
    return () => clearInterval(id)
  `,
  click: `
    const t = setTimeout(() => { st.query = el.value }, 200)
    return () => clearTimeout(t)
  `,
  effect: 'el.textContent = st.tick ?? 0'
}"></output>

When each disposer runs:

  • init — when the element unbinds.

  • effect — before the next effect run, and again when the element unbinds.

  • An event — before that same event runs again, and again when the element unbinds.

Unbind happens when moo is removed or becomes empty, when a new valid moo text replaces it, or when morph removes the element.

Returning () => clearTimeout(t) from an event cancels that timer on the next event. That is debounce. A lock that must finish should not return clearTimeout: the next event would cancel the unlock.

Typical returns: () => clearInterval(id), () => clearTimeout(t), request.abort. Cleanup failures go to reportError.

State

st and ox.state are the same deep reactive bag. Missing keys stay missing. Nested objects and arrays are reactive. Assigning an object or array replaces it.

  • st.count++ — write a reactive leaf.

  • delete st.count — remove a key.

  • st.x ??= 0 — set a client default when the key is missing.

  • ox.peek(() => st.count) — read without subscribing the active effect. Pass a function. ox.peek(st.count) already subscribed at the call site.

Writes in one expression flush together. constructor and toJSON are not usable state keys: they report proxy shape.

Flat or nested

A missing key is undefined. st.n ?? 0 and st.n ??= 0 work on that leaf.

Prefer a flat key for a flag or a scalar: st.help_open ??= false. The prefix keeps the bag readable. Flattening every field of a list (st.cart_item_0_title) does not.

Nest when the value is a structure you pass, patch, or mutate as one unit: an array you push, a form object you put in body, a subtree the server merge-patches.

javascript
st.cart ??= { items: [] }
st.cart.items.push({ title: "Seed" })
ox.post("/save", { body: st.cart })

A nested default needs the parent object first: st.help ??= { open: false }. st.help.open ??= false throws when help is missing, because JavaScript reads st.help before .open. A proxy cannot make both st.n ??= 0 and st.help.open ??= false work: the first needs a missing key to be undefined, the second needs it to be an object.

SSE state patches follow three rules:

  • Objects merge recursively.

  • Arrays replace.

  • null deletes.

__proto__, constructor, and prototype are ignored at every patch depth.

Morph

javascript
ox.morph(target, html, options?)

target may be an Element, a selector string, or null. Options must be an object. The only option is mode.

  • outer — default. With a selector: exactly one matching target and one incoming root. With no selector: each incoming root targets its matching ID, or html / head / body. Targets must exist, be unique, and not overlap.

  • append — a selector is required; all incoming roots append to that one target.

Patch HTML must contain element roots only. Whitespace and comments are allowed around them.

After a morph, Ox binds the live roots. The same moo text keeps the existing binding. Inputs and textareas keep typed .value unless the incoming value attribute changed. New scripts execute once.

Setup mistakes throw OxError synchronously.

Requests

javascript
ox.get(url, options?)
ox.query(url, options?)
ox.post(url, options?)
ox.put(url, options?)
ox.patch(url, options?)
ox.delete(url, options?)

All methods use fetch and expect text/event-stream. HTML responses are protocol errors. A 204 finishes with no stream. GET cannot have a body. The others may.

Nothing from ox.state is sent unless the caller puts it in query or body. Query and body are snapshotted once before the first fetch. Objects in body are JSON. Every request sets Ox-Request: true. Credentials are same-origin.

The second argument is always an options object. JavaScript has no keyword arguments; a positional body would be ambiguous when the payload itself has a query or headers key.

Options:

  • query — object of search parameters. Nested objects are JSON-encoded.

  • headers — additional request headers.

  • body — JSON-serializable payload.

  • reconnect"never" (default), "errors", or "always".

  • signal — parent AbortSignal.

"errors" retries network failures and retryable HTTP statuses (408, 429, 5xx). "always" also reconnects after a clean SSE close. Wire mistakes, unknown SSE events, and unknown data fields do not retry.

Each call returns { status, abort }. status is a stable reactive proxy:

  • phase"started" | "connected" | "retrying" | "finished" | "error" | "aborted"

  • activetrue until the request ends

  • attempt — reconnect count

  • errornull or { name, message }

SSE events

Update HTML:

text
event: datastar-patch-elements
data: selector #slot
data: mode outer
data: elements <strong>Ready</strong>

Allowed fields: selector, mode, elements. Omit selector to route identified roots. mode append requires a selector.

Update state:

text
event: datastar-patch-signals
data: signals {"count":7}

Allowed field: signals. The value must be a JSON object.

Unknown event names and unknown fields are protocol errors.

Errors

Setup mistakes (invalid init root, unknown request or morph options, GET with a body) throw OxError. Binding, expression, and cleanup failures go to reportError. There is no ox:error event.

Security

Ox compiles each moo= value with new Function. A Content-Security-Policy that forbids unsafe-eval blocks those expressions. Treat every moo= value as trusted code from your server. Do not interpolate request data, query strings, or other untrusted text into the attribute.

Morph HTML and SSE element patches are trusted markup. Ox inserts them into the document. Do not patch untrusted HTML.

Requests use credentials: "same-origin" and send Ox-Request: true on every call. They do not send ox.state unless the caller puts values in query or body.

API

  • ox.state — shared reactive state.

  • ox.peek(fn) — read without dependency tracking.

  • ox.init(root?) — bind [moo] under a tree you inserted yourself, or a ShadowRoot. Not needed after morph or SSE.

  • ox.morph(target, html, options?) — patch the DOM.

  • ox.get / query / post / put / patch / delete — open an SSE request.

  • ox.moo() — print the cow.

Theme

Made by Adam with Stario