Reference
The current public contract.
Boot
<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.
morphis a valid name. Ox does not dispatch amorphevent.
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 asox.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 toel.outside— listen ondocument; run only for events outsideel.prevent/stop— callpreventDefault()/stopPropagation().once/passive/capture— passed toaddEventListener.
There is no debounce, throttle, or delay option. passive and prevent cannot be combined.
Lifecycle
Ox binds an element in this order:
Compile the
mooattributeRun the previous binding’s cleanups
Run
initAttach event listeners
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.
<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.
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.
nulldeletes.
__proto__, constructor, and prototype are ignored at every patch depth.
Morph
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, orhtml/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
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— parentAbortSignal.
"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"active—trueuntil the request endsattempt— reconnect counterror—nullor{ name, message }
SSE events
Update HTML:
event: datastar-patch-elementsdata: selector #slotdata: mode outerdata: elements <strong>Ready</strong>Allowed fields: selector, mode, elements. Omit selector to route identified roots. mode append requires a selector.
Update state:
event: datastar-patch-signalsdata: 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.
Do not put untrusted text in a moo attribute. The browser runs it as JavaScript.
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.