Cookbook
Small, copyable patterns.
Set text
An effect that reads st.n writes el.textContent when that key changes.
<button moo="{ click: 'st.label_n = (st.label_n ?? 0) + 1' }">Add</button><output moo="el.textContent = st.label_n ?? 0">0</output>Hide and show
Write el.hidden from one flag. That is the whole recipe.
<button moo="{ click: 'st.help_open = !st.help_open' }">Help</button><section moo="el.hidden = !st.help_open">A little help.</section>Toggle a class
classList.toggle takes a boolean. The class is the only thing this effect writes.
<button moo="{ click: 'st.loud = !st.loud' }">Toggle</button><p moo="el.classList.toggle('text-primary', !!st.loud)">A line of text.</p>A line of text.
Nested state and arrays
Nest when the value is a list you mutate. A flat key cannot replace push.
<div moo="{ init: 'st.cart ??= { items: [] }' }"> <button moo="{ click: 'st.cart.items.push({ title: `Seed` })' }">Add</button> <output moo="el.textContent = `${st.cart.items.length} items`">0 items</output></div>Two-way text input
The event writes state. The effect copies a different value back (a reset, a patch). The !== guard skips a no-op assign.
<input autocomplete="off" placeholder="Type here" moo="{ init: 'st.echo ??= ``', input: 'st.echo = el.value', effect: 'if (el.value !== st.echo) el.value = st.echo' }"><output moo="el.textContent = st.echo || '…'"></output>Assigning value moves the caret
The jump shows when the assigned string differs. Current browsers often leave the caret alone if you write the same string back.
<input autocomplete="off" moo="{ init: 'st.jump ??= `edit here`', input: 'st.jump = el.value', effect: 'el.value = (st.jump ?? ``).toUpperCase()' }">Click in the middle, then type a letter. The field uppercases and the caret goes to the end.
Debounced search
Return clearTimeout from the event. That disposer runs before the next input. See Reference: Lifecycle.
<input type="search" moo="{ input: ` const q = el.value const t = setTimeout(() => { st.search_query = q }, 200) return () => clearTimeout(t) `}"><output moo="el.textContent = st.search_query || '…'"></output>Active search
Debounce the input, then ox.get. The server patches the hit list.
<input type="search" moo="{ input: ` const q = el.value const t = setTimeout(() => { st.search_abort?.() const request = ox.get('/ox-cookbook/search', { query: { q } }) st.search = request.status st.search_abort = request.abort }, 200) return () => clearTimeout(t) `}"><ul id="ox-search-hits" class="m-0 list-disc ps-5"> <li>Type to search.</li></ul>- Type to search.
Throttle with a lock
Keep the lock on the element. Do not return clearTimeout: the next click would cancel the unlock.
<button moo="{ click: ` if (el._busy) return el._busy = true st.lock_n = (st.lock_n ?? 0) + 1 setTimeout(() => { el._busy = false }, 2000) `}">Add</button><output moo="el.textContent = st.lock_n ?? 0">0</output>Close on outside click
Put outside on the wrapper that includes the Open button. A click on Open is otherwise outside the panel and closes it again.
<div moo="{ click: { outside: true, fn: 'st.menu_open = false' } }"> <button moo="{ click: 'st.menu_open = true' }">Open</button> <aside moo="el.hidden = !st.menu_open">Menu</aside></div>Listen on window
target: "window" or "document". Ox removes the listener when the element unbinds.
<output moo="{ init: 'st.viewport_width = innerWidth', resize: { target: 'window', fn: 'st.viewport_width = innerWidth' }, effect: 'el.textContent = `${st.viewport_width}px`'}"></output>Resize the window.
Keydown on window
Listen on window. Read evt.key. Call preventDefault only for the keys you handle. prevent: true on window would stop typing in every field.
<output moo="{ keydown: { target: 'window', fn: ` if (evt.key !== '?') return evt.preventDefault() st.help_keys = (st.help_keys ?? 0) + 1 ` }, effect: 'el.textContent = `${st.help_keys ?? 0} ? presses`'}"></output>Press ? anywhere on the page.
Prevent default
prevent: true calls preventDefault(). The link does not navigate.
<a href="#prevent-default" moo="{ click: { prevent: true, fn: 'st.stayed = true' }}">Go</a><p moo="el.textContent = st.stayed ? 'Stayed on the page.' : 'Not clicked yet.'"></p>Not clicked yet.
Run once
once: true is passed to addEventListener. The handler runs one time.
<button moo="{ click: { once: true, fn: 'st.once_n = (st.once_n ?? 0) + 1' } }"> Once</button><output moo="el.textContent = st.once_n ?? 0">0</output>Own a timer safely
Return a disposer from init. Ox runs it when the element unbinds. See Reference: Lifecycle.
<output moo="{ init: ` st.clock ??= 0 const id = setInterval(() => st.clock++, 1000) return () => clearInterval(id) `, effect: 'el.textContent = st.clock'}">0</output>Read without subscribing
ox.peek must receive a function. Writes to peeked keys do not rerun the effect.
<output moo="el.textContent = (st.visible ?? 0) + ox.peek(() => st.ignored ?? 0)">0</output><button moo="{ click: 'st.visible = (st.visible ?? 0) + 1' }">Visible</button><button moo="{ click: 'st.ignored = (st.ignored ?? 0) + 1' }">Ignored</button>Render a local list
Prefer server HTML when rows need their own moo= bindings. Morph those from SSE.
<button moo="{ click: 'st.items ??= []; st.items.push(`Item ${st.items.length + 1}`)' }">Add</button><ul moo="{ effect: ` el.replaceChildren(...(st.items ?? []).map(item => { const li = document.createElement('li') li.textContent = item return li })) `}"></ul>Submit a form
Native submit. prevent stops navigation. Read values from el.elements like a normal form.
<form moo="{ submit: { prevent: true, fn: ` const request = ox.post('/ox-cookbook/todos', { body: { title: el.elements.title.value } }) st.create = request.status return request.abort ` }}"> <input name="title" required> <button>Add todo</button></form><p moo="el.textContent = st.create?.phase ?? 'idle'"></p>idle
Post collected fields
Each input writes a key on st.draft. The request body is that object. No form, and no effect back into the field.
<div moo="{ init: 'st.draft ??= { title: ``, note: `` }' }"> <input autocomplete="off" placeholder="Title" moo="{ input: 'st.draft.title = el.value' }" > <input autocomplete="off" placeholder="Note" moo="{ input: 'st.draft.note = el.value' }" > <button moo="{ click: ` const request = ox.post('/ox-cookbook/save', { body: st.draft }) st.draft_save = request.status return request.abort ` }">Save draft</button></div><pre moo="el.textContent = JSON.stringify(st.draft ?? {}, null, 2)"></pre>{}Show request status
Bind request.status once. Effects that read phase or active update as the request moves.
<button moo="{ click: ` const request = ox.post('/ox-cookbook/save', { body: { ok: true } }) st.save = request.status return request.abort `}">Save</button><p moo="el.textContent = `${st.save?.phase ?? 'idle'} · active ${!!st.save?.active}`"></p>idle · active false
Abort a request
Keep abort() and call it from another control. The status phase becomes aborted.
<button moo="{ click: ` const request = ox.get('/ox-cookbook/feed') st.feed = request.status st.feed_abort = request.abort return request.abort `}">Start</button><button moo="{ click: 'st.feed_abort?.()' }">Abort</button><p moo="el.textContent = st.feed?.phase ?? 'idle'"></p>idle
Finish with 204
A 204 ends with no stream. reconnect defaults to never.
<button moo="{ click: ` const request = ox.get('/ox-cookbook/empty') st.empty = request.status return request.abort `}">Get</button><p moo="el.textContent = st.empty?.phase ?? 'idle'"></p>idle
Replace HTML
Outer morph needs one target and one root with the same id.
<ul id="replaced" class="m-0 list-disc ps-5"><li>Old</li></ul><button moo="{ click: `ox.morph('#replaced', '<ul id=replaced class="m-0 list-disc ps-5"><li>Fresh</li></ul>')` }"> Replace</button>- Old
Append HTML
mode: append adds roots as children of the target.
<ul id="appended"><li>Old</li></ul><button moo="{ click: `ox.morph('#appended', '<li>More</li>', { mode: 'append' })` }"> Append</button>- Old
Click to edit
Edit asks the server for a form. Save morphs the row back. Cancel restores the last title.
<article id="ox-edit-row" moo="{ init: 'st.edit_title ??= "Alpha"' }"> <p>Alpha</p> <button moo="{ click: 'ox.get("/ox-cookbook/edit", { query: { title: st.edit_title ?? "Alpha" } })' }">Edit</button></article>Alpha
Load more
ox.get sends the cursor. The server appends rows and patches more_after.
<ul id="ox-more-list" class="m-0 list-disc ps-5"> <li>Alpha</li> <li>Beta</li></ul><button moo="{ click: ` const request = ox.get('/ox-cookbook/more', { query: { after: st.more_after ?? 2 } }) st.more = request.status return request.abort `, effect: 'el.hidden = (st.more_after ?? 2) >= 6'}">Load more</button>- Alpha
- Beta
That is the full list.
Confirm, then delete
The confirm step is local. The delete posts an id. The server morphs that row away.
<li id="ox-del-alpha"> Alpha <button moo="{ click: 'st.del_alpha = true' }">Delete</button> <span moo="el.hidden = !st.del_alpha"> <button moo="{ click: 'ox.post(`/ox-cookbook/delete`, { body: { id: `alpha` } })' }">Confirm</button> <button moo="{ click: 'st.del_alpha = false' }">Keep</button> </span></li>- Alpha
- Beta
Bind a later tree
Call ox.init only for markup you insert yourself. Skip it after ox.morph or ox.post.
<div id="later-host"></div><button moo="{ click: ` const host = document.getElementById('later-host') const out = document.createElement('output') out.setAttribute('moo', 'el.textContent = st.later ?? 0') host.replaceChildren(out) ox.init(host) st.later = (st.later ?? 0) + 1` }">Insert and bind</button>