Guide
Ox is the library. moo= is how an element talks to it — what the ox says. After that joke, the rest is ordinary JavaScript: one reactive state bag, and an optional SSE connection to the server.
Is Ox a good fit?
Use Ox when the server owns the HTML and the remaining behavior is ordinary JavaScript next to the element it affects.
No build step
No component runtime
No directive language
Reactive local UI when you need it
HTML and state patches from the server when you need them
Choose something else when you need client routing, a component ecosystem, TypeScript-checked templates, or a strict CSP without unsafe-eval.
Next to Datastar and htmx
The page is already HTML. Ox exists so the rest can stay exact JavaScript.
htmx is strong when the next step is another HTTP response and a swap. Local reactivity is not its job. If every click is “ask the server, replace HTML,” htmx is the shorter path.
Datastar is a full hypermedia client with its own attribute language (data-on, data-bind, data-signals). The server talks Datastar SSE. That language is the product: the browser follows the attributes.
Ox keeps that same Datastar SSE wire, so Stario can still emit patch_elements and patch_signals. The browser side is different: one moo= attribute, one st bag, and the code in the attribute is JavaScript. There is no second grammar to learn or generate.
Use Datastar when you want the declarative client. Use htmx when swaps are the whole interaction. Use Ox when you want a few lines of JS on the element, shared reactive state, and the SSE patches Stario already knows.
Start with one attribute
Load the script. The document binds itself on load.
<script src="ox.min.js" defer></script>The document binds every [moo] after it loads. ox.morph and SSE patches from ox.post / ox.get / the other verbs already bind the HTML they insert. Do not call ox.init after those.
Call ox.init(root) only when you insert markup some other way: innerHTML, createElement, a stamped template, or a ShadowRoot.
The moo= notation
The JavaScript API is ox. The HTML attribute is moo=. Keep those two names apart: ox.post is a function call; moo= is where the element speaks.
The full form is an object. Each key is an event name. Each value is an expression string, or an object that describes that expression.
<button moo="{ click: 'st.n++', mouseenter: { fn: 'st.hover = true', once: true }}">Add</button>click, mouseenter, input, submit, morph — these are real DOM event names. The reserved keys are init and effect.
A string value is the expression body. That is the same as { fn: "…" }. The object form adds listener options: fn, target, outside, prevent, stop, once, passive, capture.
Two shortcuts:
If the attribute does not start with
{, Ox treats the whole string as an effect.So
moo="el.textContent = st.n"is the same asmoo="{ effect: 'el.textContent = st.n' }".
A block of statements belongs in { effect: "…" }. A trimmed value that starts with { is always the handler object.
<output moo="el.textContent = st.n ?? 0">0</output> <button moo="{ click: 'st.n = (st.n ?? 0) + 1' }">Add one</button>That is the whole loop: the button writes st.n. The output reads st.n. Ox remembers the read and updates the output when the value changes. Missing keys stay missing. st.n ?? 0 reads a default. st.n ??= 0 writes one.
Four useful names
Every expression receives:
el— the bound elementst— the shared reactive state bag; the same object asox.stateox— the public APIevt— the DOM event, inside event handlers only
The code is exact JavaScript:
st.n++el.hidden = !st.help_openevt.preventDefault()ox.post("/save", { body: { title: st.title } })State is one bag for the page. Prefix keys by feature (st.help_open, st.search_query) so names stay readable and every default is a single ??=. Nest when the value is a list or a payload you send as one unit. The reference covers that shape.
Do not store application data on constructor or toJSON. Those names report proxy shape.
Lifecycle and cleanup
Ox binds in this order:
Validate the new attribute
Clean up the previous binding
Run
initAttach event listeners
Start
effect
Return a function (or an array of functions) when you own a timer, listener, or request:
<output moo="{ init: ` st.tick ??= 0 const id = setInterval(() => st.tick++, 1000) return () => clearInterval(id) `, effect: 'el.textContent = `tick ${st.tick}`'}"></output>Init cleanup runs when the element unbinds.
Effect cleanup runs before the next effect and when the element unbinds.
Event cleanup runs before that event runs again and when the element unbinds.
If a morph leaves the moo text unchanged, the existing binding stays alive.
Add the server when useful
Browser-only UI can stop here. For server work, Ox uses fetch and expects text/event-stream.
<button moo="{ click: ` const request = ox.post('/save', { body: { title: st.title } }) st.save = request.status return request.abort `}">Save</button>Nothing from st is sent unless you put it in query or body. GET cannot have a body. The other verbs may. Every verb sends Ox-Request: true. Credentials are same-origin. Query and body are snapshotted once, including retries.
The second argument is an options object, not a positional body. JavaScript has no keyword arguments, so { body, query, headers, reconnect, signal } is how optional named fields work. A positional second argument would collide with a JSON body that happens to contain query or headers. Defaults: reconnect is "never"; omitted query / body / headers / signal send nothing extra.
The returned status is ordinary reactive state:
{ phase: "started" | "connected" | "retrying" | "finished" | "error" | "aborted", active: true, attempt: 0, error: null | { name, message }}The default is reconnect: "never". "errors" retries network and retryable HTTP failures. "always" also reconnects after a clean SSE close. A 204 finishes with no stream. Unknown events, bad patches, and other protocol mistakes stop.
Use native fetch for arbitrary APIs. Ox requests consume Datastar-compatible SSE only.
From Stario, emit the same events with SSE(w):
from stario import Context, Writerfrom stario.datastar import SSE async def save(c: Context, w: Writer) -> None: sse = SSE(w) sse.patch_elements('<p id="notice">Saved</p>', selector="#notice") sse.patch_signals({"saved": True})Ox accepts only morph modes outer (default) and append. Do not send extra SSE fields such as onlyIfMissing or namespace. Unknown fields are protocol errors.
The Ox landing runs this shape: local cups, then Print ticket posts them. Stario morphs the ticket.
Morph HTML
Every patch has a home. IDs preserve identity. Strict roots make server mistakes visible.
ox.morph("#profile", '<section id="profile">Updated</section>')Outer mode with a selector requires exactly one matching target and one incoming root.
ox.morph(null, ` <header id="account">Ada</header> <aside id="alerts">Saved</aside>`)With no selector, each root targets the same ID in the live document — or html, head, or body. Missing, duplicate, or overlapping targets throw.
ox.morph("#results", html, { mode: "append" })Append requires one explicit target. All incoming roots append to it.
Inputs and textareas keep typed .value unless the incoming value attribute is different from the live one.
The wire
Patch elements:
event: datastar-patch-elementsdata: selector #noticedata: elements <p id="notice">Saved</p>Omit selector to route one or more identified roots. Set mode append only with a selector. Allowed fields: selector, mode, elements.
Patch state:
event: datastar-patch-signalsdata: signals {"user":{"name":"Grace"}}Allowed field: signals. The JSON is merge-patch shaped. Objects merge, arrays replace, and null deletes. Nested proxy identity stays stable. __proto__, constructor, and prototype are ignored at every depth.
The whole mental model
moo=connects an element to behavior. The ox says moo.stholds shared reactive state.Effects rerun when values they read change.
Events write state or call the server.
datastar-patch-signalsupdates state.datastar-patch-elementsupdates HTML.
That is enough to build with Ox. Open the Cookbook for copyable patterns or the Reference for exact options and payloads.
Ox expressions use new Function. Your Content Security Policy must allow unsafe-eval. Do not put untrusted text in a moo attribute.