Rouse
Rouse (
rousejson npm) is a directive-driven reactivity and state-sync layer for server-rendered HTML. The server sends the markup;data-rz-*attributes bind it to reactive state, events, and network requests. Logic lives in plain JavaScript scopes and stores, never in attribute expressions. Rouse is pre-release. Source: github.com/dehkr/rouse.
Setup
Create an app, register its scopes, stores, interceptors, and listeners, then start it. Starting scans the page and binds every directive.
import { rouse } from 'rousejs';
// Creates the app with config; config is read-only after creation
const app = rouse({
root: '#app',
baseUrl: '/api',
headers: { 'X-Tenant': '42' },
});
app.scope('counter', () => { /* ... */ }); // defines the API inside data-rz-scope="counter"
app.store('prefs', { theme: 'dark' }); // app-wide state, addressed as @prefs in HTML
app.interceptor('request', addAuth); // edits every request before it's sent
app.on('rz:fetch:error', showToast); // hears lifecycle events from the whole app
app.start(); // scans the root and binds directives
start() scans the DOM once and then watches for added nodes. Registration applies to DOM scanned afterward and never retroactively. <script data-rz-store> stores don't exist until start(). destroy() is terminal.
Install with npm install rousejs, or skip the install and import from a CDN in a <script type="module">: https://cdn.jsdelivr.net/npm/rousejs@0.15.0/dist/rouse.js (development) or https://cdn.jsdelivr.net/npm/rousejs@0.15.0/dist/rouse.min.js (production). Rouse ships as ES modules only.
- npm
rousejs-> minified build; under a bundler'sdevelopmentcondition -> development build. rousejs/devandrousejs/minpick one explicitly.- Only the development build logs
[Rouse]authoring warnings.
Config options
Options passed to rouse(). Every key is optional.
| Config key | Default | Meaning |
|---|---|---|
root |
document.documentElement |
Element or selector the app owns |
baseUrl |
'' |
Prefixed to relative URLs in rz-fetch, rz-push, rz-pull, rz-sse, app.fetch, app.sse |
headers |
{} |
Sent with every request; a null value removes a header |
credentials |
browser's (same-origin) |
fetch credentials for every request |
timeout |
0 (none) |
Abort every request after this many ms; a per-request timeout overrides it |
wake |
ready |
Default scope activation; rz-wake overrides it per scope |
Syntax
Directives are data-rz-* attributes whose values name state, methods, and triggers. Values are literal text, never expressions.
<div data-rz-scope="profile">
<!-- Bare path: a key on the nearest scope -->
<h2 data-rz-text="user.name"></h2>
<!-- @store.path: a field in a store; @store::status: its read-only sync status -->
<span data-rz-text="@cart.count" data-rz-class="busy: @cart::status.syncing"></span>
<!-- Pairs: comma-space between them, colon-space inside each -->
<a data-rz-attr="href: profileUrl, aria-current: isCurrent">Profile</a>
<ul>
<template data-rz-render="@cart.items">
<!-- %.field: a field on the current rz-render item -->
<li data-rz-text="%.title"></li>
</template>
</ul>
</div>
- Every directive is an attribute with the
data-rz-prefix:data-rz-on,data-rz-scope. Barerz-onis ignored. @nameis a store,@name.patha field in it,@name::statusits read-only status.- A bare path (
count,user.name) resolves against the nearest enclosingdata-rz-scope. %is the currentrz-renderitem,%.fielda field on it,%::index/%::positionits zero- and one-based position.- Values are literal text: no expressions, interpolation, or call arguments.
save(1)is looked up as a literal key and matches nothing. Put logic in a getter or method and name it. - Per-element parameters go in ordinary
data-*attributes, read asel.datasetin a handler orhost.datasetin a setup. - Paths are dot paths and may index arrays:
user.name,@names.list.0. - Pairs are separated by a comma plus whitespace (
,); key and value by a colon plus whitespace (:). Quote a value containing,or:('color: red').rz-disableandrz-indicatorare the exceptions: each takes its whole value as one selector list, sodata-rz-disable="#e1, #e2"needs no quotes.
Directives
Each directive does one job: bind state to the DOM, handle an event, make a request, or route its response.
<!--
The todos scope activates on the first hover or focus inside the section.
The button starts disabled, so nothing submits until the scope binds isEmpty.
On submit, POST the form's fields to its action (/todos) and append the returned
HTML to #todo-list. The fieldset is disabled while the request runs.
-->
<section data-rz-scope="todos" data-rz-wake="interact">
<form
action="/todos"
method="post"
data-rz-fetch="submit"
data-rz-place="beforeend: #todo-list"
data-rz-disable="#todo-fields"
>
<fieldset id="todo-fields">
<!-- todos exposes title and isEmpty -->
<input name="title" data-rz-model="title">
<button disabled data-rz-attr="disabled: isEmpty">Add</button>
</fieldset>
</form>
<ul id="todo-list"></ul>
</section>
All directives
Every directive, its job, and a typical value.
| Directive | Purpose | Example |
|---|---|---|
rz-scope |
Bind a registered scope to a region; empty value = anonymous boundary | data-rz-scope="search" |
rz-wake |
Defer scope activation until all listed triggers fire (AND) | data-rz-wake="intersect timeout-[2s]" |
rz-text |
Write state as text | data-rz-text="@user.name" |
rz-html |
Write state as HTML (unsanitized) | data-rz-html="previewHtml" |
rz-attr |
Set attributes; false/null removes, true sets empty; on aria-*, booleans write "true"/"false". Not class or style: use rz-class / rz-style |
data-rz-attr="href: profileUrl, aria-expanded: open" |
rz-prop |
Set DOM properties, type preserved | data-rz-prop="checked: done, items: @cart.items" |
rz-class |
Toggle the classes on the left by the state on the right, or write a class string or { class: bool } object from a bare path |
data-rz-class="active selected: isChosen", data-rz-class="themeClass" |
rz-style |
Toggle a quoted declaration, set one property, or write a declaration string or object from a bare path | data-rz-style="'color: red; font-weight: bold': hasError, background: @theme.color" |
rz-model |
Two-way bind an input, textarea, select (multiple gives an array), checkbox, radio, or contenteditable. Default trigger, resolved at bind: input for text, textarea, and contenteditable; change for select, checkbox, and radio. Custom elements bind .value and need an explicit trigger |
data-rz-model="query", data-rz-model="value-changed: @names.list.0" |
rz-on |
Call a scope or store method on a trigger | data-rz-on="click: save, submit: send" |
rz-render |
On a <template>: render an array (per item), number (n copies), object (once), or boolean (if) |
<template data-rz-render="@todos.items"> |
rz-key |
Stable key for rz-render lists that reorder or refetch |
data-rz-key="id" |
rz-fetch |
Request on a trigger; subject is [METHOD] URL (default GET), either part optional (submit: PUT keeps the form's action) |
data-rz-fetch="click: POST /todos" |
rz-place |
Where an HTML response goes: position: selector, a selector, a position, or null. Default: host innerHTML |
data-rz-place="beforeend: #log" |
rz-deposit |
Merge a JSON response into whole stores | data-rz-deposit="@results" |
rz-send |
Payload: scope paths, @store/@store.field, %/%.field, inline JSON, selector: <css> |
data-rz-send='query, @filters, {"page": 2}' |
rz-headers |
Request headers for a fetch, or on a store script for its sync; null removes |
data-rz-headers="X-Tenant: 42" |
rz-request |
Fetch options: timeout, abort-key, credentials, keepalive, redirect, cache |
data-rz-request="timeout: 5s, keepalive" |
rz-indicator |
Elements that get the rouse-request class in flight (default: the trigger; null = none) |
data-rz-indicator="#spinner" |
rz-disable |
Elements disabled in flight; empty = the host. A <form> can't be disabled: name its <fieldset> |
data-rz-disable |
rz-store |
On <script type="application/json">: declare a store, optional sync endpoint |
data-rz-store="cart: /api/cart" |
rz-push |
Send the whole store (PATCH, JSON Merge Patch body) |
data-rz-push="click: @cart" |
rz-pull |
Refresh the whole store (GET) |
data-rz-pull="ready interval-[30s]: @cart" |
rz-sse |
Open an EventSource; one stream per element |
data-rz-sse="ready: /events" |
rz-close |
Triggers that close that element's stream (OR) | data-rz-close="sse-[done]" |
Method is only ever in the rz-fetch subject (or a form's method / a button's formmethod), never in rz-request. rz-headers, rz-request, and rz-send apply to fetch triggers only.
Trigger grammar
Event-driven directives pair a trigger (when) with a subject (what): [trigger]: [subject]. rz-on, rz-fetch, rz-push, rz-pull, rz-sse, and rz-model take it.
<!-- Call save on click -->
<button data-rz-on="click: save">Save</button>
<!-- Call search 300 ms after typing stops; a | starts the modifiers -->
<input data-rz-on="input|debounce-[300ms]: search">
<!-- Two pairs: Escape anywhere clears; Ctrl+Enter submits -->
<input data-rz-on="keydown|window.key-[escape]: clear, keydown|ctrl.key-[enter]: submit">
<!-- Two triggers sharing one subject: either fires it -->
<a href="/panel" data-rz-on="pointerover focusin: preload">Panel</a>
data-rz-on="click: save": comma-space separates pairs, space separates triggers sharing a subject (data-rz-on="pointerover focusin: preload"fires on either).- One
|separates the event from dot-separated modifiers:click|once.prevent,input|debounce-[300ms]: search. - A bracketed argument follows
-[:debounce-[300ms],key-[enter],timeout-[5s],media-[(min-width: 640px)]. One argument only; commas inside brackets are data. - A trigger is any DOM or custom event name, including Rouse's lifecycle events (
data-rz-on="rz:fetch:success: stamp") and an event a server sends withRouse-Trigger. Colons inside a name are fine, because only colon plus whitespace ends the trigger. Any trigger works on any of these directives:data-rz-fetch="edit-[@filters]|debounce-[400ms]: /search". - Triggers are always explicit, except on
rz-model. A colon-less value on the network directives andrz-onis triggers only (data-rz-fetch="click"takes its URL fromhref/action). - A second
|(click|a|b), an empty segment (click||once), or a leading|warns and skips the trigger. An unknown modifier warns. An argument on an event that takes none is ignored. rz-wakeandrz-closetake a trigger list: the whole value is space-separated triggers, no subject. A comma there warns.
Modifiers
Modifiers follow the | and chain with dots. They filter a trigger, time it, or change where it listens.
| Modifier | Effect | Example |
|---|---|---|
once |
Remove after the first fire that passes the filters | click|once |
prevent |
preventDefault() |
submit|prevent |
stop |
stopPropagation() |
click|stop |
stop-immediate |
stopImmediatePropagation() |
click|stop-immediate |
capture |
Listen in the capture phase | focus|capture |
passive |
Passive listener | scroll|passive |
self |
Only when the element itself is the target | click|self |
outside |
Only for events from outside the element (listens on document) |
click|outside |
window |
Listen on window; filters still apply to the element |
resize|window |
document |
Listen on document |
visibilitychange|document |
root |
Listen on app.root |
rz:fetch:success|root |
debounce, debounce-[wait] |
Fire after events stop for wait (default 300 ms) |
input|debounce-[300ms] |
throttle, throttle-[wait] |
Fire at most once per wait (default 150 ms) |
scroll|throttle-[100] |
leading |
Fire on the leading edge | click|throttle.leading |
trailing |
Fire on the trailing edge | click|debounce.leading.trailing |
key-[key] |
Match a KeyboardEvent.key, lowercase; repeatable |
keydown|key-[enter] |
ctrl, alt, shift, meta |
Require the key held | keydown|ctrl.key-[s] |
loose |
Allow held keys the trigger didn't name | keydown|key-[enter].loose |
- Waits:
-[300]is ms; unitsms,s,m. - Edges: debounce fires trailing, throttle both. Naming
leadingortrailingreplaces the defaults:click|throttle.leadingis leading only,click|debounce.leading.trailingboth. key-[…]takes anyKeyboardEvent.keyin lowercase (key-[enter],key-[arrowup],key-[/]);key-[space]for the spacebar.[ ( { " 'can't be written; an unbalanced one swallows the rest of the value.- Key matching is exact once a key or held flag is named:
keydown|key-[enter]ignores Shift+Enter; writekeydown|shift.key-[enter]for that. A barekeydownsees every key. - A held flag alone matches nothing on a keyboard event (
keydown|meta) and filters a mouse event (click|ctrl). - Modifier keys don't auto-repeat on macOS. For "while held", pair
keydown|key-[alt]withkeyup|key-[alt].
Trigger sources
Triggers that aren't DOM events:
| Source | Fires | Example |
|---|---|---|
ready |
Once the app is up; immediately for anything bound later | ready: @cart |
wake |
When the enclosing scope activates (see rz-wake), and on each reconnect |
wake: /panel |
none |
Never; binds nothing | none |
timeout-[delay] |
Once, after delay |
timeout-[5s] |
interval-[delay] |
Every delay |
interval-[30s] |
media-[query] |
When the media query matches, now or later | media-[(min-width: 640px)] |
intersect, intersect-[ratio] |
Each time the element enters the viewport, at least ratio visible (0–1, default 0) |
intersect-[0.5]|once |
interact |
On each pointerover or focusin on the element; add once for the first only |
interact|once |
idle |
Once, when the browser is idle | idle |
edit-[@store], edit-[@store.key] |
When the user edits the store, or that top-level key; framework writes don't count | edit-[@cart]|debounce-[500ms] |
sse-[name] |
On a server-sent event named name from this element's stream or its descendants'; sse-[message] for unnamed messages |
sse-[done], sse-[tick]|root |
On a source only once, timing, and its argument apply (sse also takes root); other modifiers are ignored without a warning.
Scopes
A scope is a synchronous setup function that returns a plain object: the keys that bare paths in its region resolve against. A scope is not a component: no template, props, or re-render. Its return value is not made reactive; reactivity comes from the signal and reactive state the setup creates. Expose a signal through a getter, with a setter if rz-model writes it. A reactive object or array can be returned as-is.
import { reactive, signal } from 'rousejs';
app.scope('search', ({ host, app, term }) => {
// A signal is read through a getter; add a setter if rz-model writes it
const query = signal('');
// Reactive objects and arrays track themselves; return them as-is
const results = reactive([]);
async function run() {
// signal: term aborts the request if the scope is torn down mid-flight
const { data, error } = await app.fetch(`/search?q=${encodeURIComponent(query())}`, {
signal: term,
});
if (!error) {
results.splice(0, results.length, ...data.items);
}
}
// Search once when half the region scrolls into view
app.on(host, { intersect: 0.5 }, run, { once: true, signal: term });
return {
// Getter for reading, setter so rz-model can write
get query() { return query(); },
set query(v) { query(v); },
results,
run,
clear() { query(''); },
// Handlers get { el, e, render }; render.index is the clicked row
remove({ render }) { results.splice(render.index, 1); },
// Optional lifecycle hooks
connect() {},
disconnect() {},
};
});
<div data-rz-scope="search">
<!-- Typing writes query; run fires 300 ms after typing stops; Escape anywhere clears -->
<input
data-rz-model="query"
data-rz-on="input|debounce-[300ms]: run, keydown|window.key-[escape]: clear"
>
<ul>
<!-- One <li> per result, matched by id across re-renders -->
<template data-rz-render="results" data-rz-key="id">
<li>
<span data-rz-text="%::position"></span>. <span data-rz-text="%.title"></span>
<button data-rz-on="click: remove">x</button>
</li>
</template>
</ul>
</div>
- A returned
reactiveobject or array is the same proxy for the scope's lifetime. Mutate it in place (results.splice(…),rz-model="form.name"); assigning a new value to the key isn't reactive. ScopeCtxis{ host, app, term, scan }.termis anAbortSignalaborted on teardown: passsignal: termtoapp.on,app.fetch,app.sse, andapp.interceptorto end them with the scope.scan(node)binds DOM you add by hand.- Handlers and formatters receive
{ el, e, render: { item, index } }. A scope or store function named inrz-text/rz-attr/etc. is called and its return value written. - Give client-created list items a stable key once, at creation:
items.push({ id: createKey(), text })withdata-rz-key="id". Never call it in a getter. - Nested scopes are independent; share state through a store, signal through DOM events (
dispatch(el, 'name', detail)fromrousejs). - Register scopes at app level with
app.scope(name, setup), never inside another setup.
Lifecycle hooks
connect() and disconnect() are optional methods on the returned object. Both are called with this as that object and no arguments.
| Hook | Runs | Order |
|---|---|---|
connect() |
Once per instance, after the region's directives bind | setup -> rz:scope:init -> bind -> connect() -> rz:scope:connect |
disconnect() |
On teardown: the host leaves the DOM, or app.destroy() |
term aborts -> bindings detach -> disconnect() -> rz:scope:disconnect -> rz:scope:destroy |
- Use
connect()for work that needs bound DOM: measuring, focusing, or mounting a third-party widget. Setup runs before any directive binds. - With
rz-wake,connect()waits for activation. Setup waits too. - Prefer
signal: termfor cleanup. It also covers a setup that throws partway, which runs neither hook. Keepdisconnect()for undo worktermcan't reach, such as a widget's owndestroy(). - Moving the host is a removal and an insertion: the instance tears down and setup runs again. State that must survive a move belongs in a store.
- A throwing hook is logged; the mount or teardown still completes.
Stores
A store is one flat reactive object: data, getters (cached computeds; keep them pure), and methods (this is the store). Only plain data syncs. The root must be an object.
const cart = app.store('cart', {
items: [],
get total() { return this.items.reduce((s, i) => s + i.price, 0); },
clear() { this.items = []; },
}, { url: '/api/cart' });
cart.items.push({ id: 1, price: 5 }); // local only; the store now reads dirty
await app.stores.push('cart'); // PATCH /api/cart with the whole store
// or save on every edit instead of calling push yourself:
app.on({ edit: 'cart' }, () => app.stores.push('cart'), { debounce: 500 });
<!-- Autosaves 500 ms after the last edit; the button saves on demand -->
<script
type="application/json"
data-rz-store="user: /api/user"
data-rz-push="edit-[@user]|debounce-[500ms]: @user"
>{ "name": "Ada" }</script>
<input data-rz-model="@user.name">
<button
data-rz-push="click: @user"
data-rz-attr="disabled: @user::status.syncing"
>Save</button>
<span data-rz-class="unsaved: @user::status.dirty">Unsaved changes</span>
rz-push,rz-pull, andrz-depositalways name their store, including on the store's own script (<script data-rz-store="feed" data-rz-sse="ready: /feed" data-rz-deposit="@feed">). A path (@user.profile) is rejected: stores sync whole.@name::statusis{ syncing: false | 'push' | 'pull', error: { code, message } | null, lastSync, dirty, dirtyRoots }.- Server responses are applied as JSON Merge Patch (RFC 7396): absent keys stay, objects merge, arrays and primitives replace,
nulldeletes the key. - A push in progress holds later pushes and sends one follow-up when it succeeds. A failed push changes no data: the store stays dirty and
status.erroris set. Roll back yourself:app.on('rz:push:error', () => app.stores.revert('cart')). - Push sends
Content-Type: application/merge-patch+json. app.store(name, data, config?)takes the endpoint asconfig.url, plus request options (headers,timeout,credentials, …). It returns the store itself, the same objectapp.stores.get(name)returns.
Declarative stores
A <script type="application/json" data-rz-store="name: /endpoint"> declares a store when it's scanned: at start(), or when inserted later. The body is the initial data and must be a JSON object; an empty body starts the store as {}. Invalid JSON logs an error, in production too, and creates no store.
The script element is the store's declaration. Replacing it with a new script of the same name re-declares the store:
- Data. The new body replaces the data and becomes the new
reset()andrevert()target. Unsaved edits are discarded, and the store reads clean. - Config. The endpoint and headers come from the new script. A missing endpoint or
rz-headersclears that setting, including headers set in JS. Other options (timeout,credentials, …) are kept. - Empty body. The data is kept; only the config is replaced.
<!-- A server re-render swaps the script; the store re-seeds from the new body -->
<script type="application/json" data-rz-store="cart: /api/cart">
{ "items": [] }
</script>
- Removing the script leaves the store registered. Only
app.stores.remove(name)deletes a store. - A second script with the same name, while the first is still in the page, is ignored with a dev warning. So is a script for a store created with
app.store(). - Bindings follow the store, not the script: the same
@cartpaths keep updating across a re-declaration.
Store manager methods
app.stores manages every store by name. It's a manager, not a record of stores.
| Method | Returns | Notes |
|---|---|---|
get(name) |
The store, or undefined |
The same reactive object @name reads in markup and app.store() returns. Writes through it update bindings and mark the store dirty |
has(name) |
true if the store exists |
A <script data-rz-store> store exists only after start() |
status(name) |
{ syncing, error, lastSync, dirty, dirtyRoots }, or undefined |
The same reactive object as @name::status. Its dirtyRoots is an object keyed by root (dirtyRoots.items), not an array |
isDirty(name, root?) |
true if the store, or one top-level key, has unsynced edits |
root is a top-level key ('items'), not a path. Updates a microtask after an edit |
dirtyRoots(name) |
Array of top-level keys with unsynced edits; empty when clean | A copy: changing it doesn't affect the status |
snapshot(name) |
Plain, non-reactive deep copy of the current data | Data fields only: getters and methods are dropped, and non-plain objects (Date, Map) become {} with a dev warning. Safe to serialize |
baseline(name) |
Plain, non-reactive deep copy of the last synced data | What revert() restores and dirty checks compare against. Same copy rules as snapshot() |
push(name) / pull(name) |
Promise<void>; never rejects |
Syncs now; check status(name).error for the outcome. A push during another push waits and sends once |
revert(name, path?) |
true if anything changed |
Restores the last synced data. With path, restores one field or branch |
reset(name) |
Restores the initial data: from creation, the last update(), or the last re-declared script. The store then reads clean |
|
commit(name) |
Marks the current data synced; sends nothing. Use it when the data reached the server another way | |
update(name, state) |
The store | Replaces the data; it becomes the new reset() and revert() target, and the store reads clean. Sends nothing |
deposit(name, payload) |
false if the store is missing or a listener canceled |
Merges a payload as JSON Merge Patch, the way rz-deposit does. The payload's keys become synced; other unsaved edits stay |
config(name, partial) |
Merges partial into the sync config (url, headers, timeout, …) |
|
remove(name) |
Deletes the store. Existing references keep working but stop syncing and firing edit triggers |
Render
rz-render repeats a <template> over a value. In each instance, % is that instance's item; bare paths and @store paths resolve as they do outside the template.
<!-- A keyed list from a store; each row reads its item through % -->
<template data-rz-render="@todos.items" data-rz-key="id">
<li>
<span data-rz-text="%::position"></span>. <span data-rz-text="%.title"></span>
<!-- Stores are reachable and bare paths fall through to the enclosing scope -->
<span data-rz-text="@todos.owner"></span>
</li>
</template>
<!-- Boolean mode: renders the instance while the value is true -->
<template data-rz-render="@todos.isEmpty"><p>Nothing to do.</p></template>
Render modes
What rz-render renders for each kind of value, and what % holds in each instance.
| Value | Renders | % |
|---|---|---|
| Array | One instance per item | The item, of any type |
| Plain object | One instance | The object |
| Number | n instances, floored; negative or non-finite renders none |
The number, in every instance |
| Anything else (boolean mode) | One instance while truthy, none while falsy | The value |
%is the item in array mode and the rendered value in every other mode.%.fieldneeds an object; on a primitive it'sundefined.%::index/%::positionwork in every mode.- Each instance binds against its item:
%.field, nested%.user.name. Metadata (%::index,%::position) is readable anywhere in the instance but read-only and not item data:rz-modelcan't write it andrz-senddoesn't send it. - Bare paths fall through to the enclosing scope.
%is the nearest item only; outer items aren't reachable. - Without
rz-key, instances are reused by position.
Render targets
An item with a renderTarget field renders its instance there instead of beside the template.
<!-- Each card renders into the column its renderTarget names -->
<template data-rz-render="@board.cards" data-rz-key="id">
<article data-rz-text="%.title"></article>
</template>
<section id="todo"></section>
<section id="done"></section>
<!-- @board.cards: [{ "id": 1, "title": "Write docs", "renderTarget": "#done" }] -->
- A string is a selector, matched within the app root (first match). An element is used only if it's inside the app root. Otherwise the instance renders in place, with a dev warning.
- It's read once, when the instance is built. Changing
renderTargetlater doesn't move a reused instance; it moves only when the instance is rebuilt under a new key. - Instances are appended to the target in the order they're built, and reordering the list doesn't reorder them there.
- A moved instance still binds against its item and the template's scope.
renderTargetis ordinary item data, so a synced store pushes it with the item. Use a selector there; an element can't be serialized.
Requests
An rz-fetch sends its element's data. Payload precedence: rz-send -> the element's own field -> a form's or fieldset's enabled fields. GET and HEAD send it as query parameters; other methods send a body in the encoding you declare.
<!-- GET /search?q=…: a field sends its own name and value -->
<input name="q" data-rz-fetch="input|debounce-[300ms]: /search" data-rz-place="#results">
<!-- POST a JSON body built from scope and store state; the Content-Type picks the encoding -->
<button
data-rz-fetch="click: POST /notes"
data-rz-send="title, @prefs"
data-rz-headers="Content-Type: application/json"
>Save</button>
<!-- Files need multipart, declared the HTML way -->
<form action="/avatar" method="post" enctype="multipart/form-data" data-rz-fetch="submit">
<input type="file" name="avatar"><button>Upload</button>
</form>
- Payload.
rz-sendreplaces the element's own data. A field sends{ name: value }; a nameless field sends nothing. A form or fieldset sends its enabled fields plus the submitter's name and value, and a submitter'sformaction,formmethod, andformenctypebeat the form's. Any other element sends nothing withoutrz-send. - Encoding, for methods with a body. Precedence:
Content-Typeheader (appheadersorrz-headers) -> buttonformenctype-> formenctype->application/x-www-form-urlencoded. Never inferred from the payload. AContent-Typethat isn't JSON, urlencoded, or multipart sends form data under that type, with a dev warning. - Shape. JSON keeps nesting and types. Form encodings flatten: arrays become repeated keys, numbers and booleans become strings, nested objects are dropped. A file needs multipart: urlencoded sends its name and JSON drops it. Each loss warns in dev.
app.fetchbody. An object is sent as JSON,URLSearchParamsas urlencoded, and strings and native bodies (FormData,Blob, …) as is; a declaredContent-Typewins. A body on GET or HEAD throws: passparams. Abodyreplaces the trigger element's data, andrz-sendis ignored with a warning.
Overlapping requests
Every request has an abort key: the trigger element's own by default, or a shared one from rz-request="abort-key: search" (abortKey in app.fetch). An app.fetch without triggerEl has none unless you set it.
- A new request under a key aborts a read in flight under it. The old request fires
:abort. - While a write (any method but
GET,HEAD,OPTIONS,QUERY) is in flight, new requests under its key are dropped, with no events. The server may already have acted on the write, so aborting it would only discard its response. - A read that fails before any response arrives (a dropped connection) is retried once, immediately. Timeouts, cancellations, HTTP errors, and writes are never retried.
Server responses
The server answers a Rouse request with HTML or JSON, and can steer where it goes with Rouse-* response headers.
HTTP/1.1 200 OK
Content-Type: text/html
Rouse-Place: beforeend: #log
Rouse-Trigger: entry-added
<li>Saved at 10:42</li>
Rouse-Place appends the HTML to #log, overriding the trigger's rz-place. Rouse-Trigger dispatches entry-added from the trigger after rz:fetch:success. A data-rz-on="entry-added: refresh" on the trigger or an ancestor hears it.
Requests identify themselves: a fetch sends Rouse-Request: true; a push or pull sends Rouse-Sync: push | pull and Rouse-Store: <name>. A null header value in rz-headers drops any of them.
A fetch response is routed by content type:
- HTML goes to the
rz-placetargets (default: the trigger'sinnerHTML;data-rz-place="null"for none). - JSON merges into the
rz-depositstores, as JSON Merge Patch. Withoutrz-depositit goes nowhere. - Files only fire
rz:fetch:success:file. - Not routed: error responses, an empty body, and an
app.fetchwithouttriggerEl, unless the server sendsRouse-PlaceorRouse-Deposit. An empty body withRouse-Placeplaces'', so a204withRouse-Place: deleteremoves the trigger. See Errors for routing error responses.
Unnamed rz-sse messages route the same way, without headers.
Response headers
The server can override the client with response headers. The header wins over the markup.
| Header | Effect | Applies to |
|---|---|---|
Rouse-Place |
Replaces rz-place for this response, same grammar (beforeend: #log, delete). Beats rz-place="null" |
fetch |
Rouse-Deposit |
Replaces rz-deposit for this response (@results) |
fetch |
Rouse-Trigger |
Dispatches the named event from the trigger element after :success; detail is the response |
fetch, push, pull |
Rouse-Redirect |
Navigates the page to the URL; the rest of the response is ignored | fetch |
Rouse-Push-Url |
history.pushState to the URL; same origin only |
fetch |
Rouse-Replace-Url |
history.replaceState to the URL; same origin only. Rouse-Push-Url wins if both are sent |
fetch |
A redirect the browser follows (3xx) navigates the page to the final URL when it's same-origin, and fails with REDIRECTED when it isn't.
Errors
A failed request never rejects. It resolves with error set and fires rz:fetch:error (rz:push:error, rz:pull:error for sync). Error responses skip rz-place and rz-deposit: a 4xx/5xx body reaches the page only if the server routes it with a header or your code renders it.
Error codes
error is null on success, else { code, message, body?, parseError?, original? }. code identifies the failure.
code |
Means |
|---|---|
| HTTP status (number) | Non-2xx response. body is the parsed error body; parseError is set if it didn't parse |
TIMEOUT |
The timeout elapsed |
CANCELED |
Aborted by a newer request or a signal (fires :abort, not :error), prevented in :config, or dropped by a guard |
NETWORK_ERROR |
No response, after the retry for reads; original is the thrown error |
PARSE_ERROR |
A 2xx body that couldn't be parsed: malformed JSON, or an event stream (open it with rz-sse) |
REDIRECTED |
The browser followed a redirect to another origin |
INTERNAL_ERROR |
Rouse threw unexpectedly |
Handling strategies
Pick by who owns the error UI. Strategies can be combined: a server-routed form error and an app-wide toast can handle the same response.
| Strategy | Mechanism | Fits |
|---|---|---|
| Server-routed | Rouse-Place or Rouse-Deposit on the error response |
Server-rendered error HTML, field validation; no client code |
| Trigger-local | data-rz-on="rz:fetch:error: showError" on the trigger or an ancestor |
One form or widget |
| App-wide | app.on('rz:fetch:error', fn) |
Toasts, logging, redirect to login on 401 |
| Interceptor | app.interceptor('error', fn) |
Normalize or rewrite error for every fetch, push, and pull |
| Inline | const { error } = await app.fetch(…) |
Code that calls app.fetch |
| Store sync | @name::status.error, rz:push:error, rz:pull:error |
Failed push or pull |
<!-- Field validation: a 422 JSON body deposits per-field messages -->
<script type="application/json" data-rz-store="form-errors">{}</script>
<form action="/signup" method="post" data-rz-fetch="submit">
<input name="email" data-rz-class="invalid: @form-errors.email">
<p data-rz-text="@form-errors.email"></p>
<button>Sign up</button>
</form>
HTTP/1.1 422 Unprocessable Content
Content-Type: application/json
Rouse-Deposit: @form-errors
{ "email": "Enter a valid email address." }
// App-wide: e.detail is the full response; data is the parsed error body
app.on('rz:fetch:error', (e) => {
const { error, data } = e.detail;
if (error.code === 401) location.assign('/login');
else toast(error.message);
});
// Interceptor: listeners and app.fetch callers see the returned error
app.interceptor('error', (error) => {
if (error.code === 'TIMEOUT') {
error.message = 'The server took too long to respond.';
}
return error;
});
- Clear field errors. Send every field on each response, with
""for valid ones, and sendRouse-Depositon the success response too. A field left out keeps its old message. - Typed sub-events.
rz:fetch:error:htmlandrz:fetch:error:jsonfire right afterrz:fetch:error, filtered by the error body's content type. - Bubbling. Error events bubble to
app.root, so one listener on a container handles every trigger inside it. - Order. Error interceptors run before any error event, after the read retry.
- Cancellations aren't errors.
CANCELEDfrom a newer request or a signal fires:abort, not:error, so it needs no error UI.
Programmatic API
Everything markup does is also callable from JavaScript, through the app and a few package exports.
// Request through the app's config and interceptors; never throws
const { data, error } = await app.fetch('/api/user', { timeout: 5000 });
// Listen with the same modifiers as markup: keyup|ctrl.key-[arrowup]
app.on('keyup', bump, { ctrl: true, key: 'ArrowUp' });
// Insert HTML the way rz-place would
app.place('#log', '<li>Done</li>', { position: 'beforeend' });
// Add a header to every request
app.interceptor('request', (config) => {
config.headers['X-Trace'] = crypto.randomUUID();
return config;
});
App members
Methods and properties on the app that rouse() returns.
| Member | Returns | Notes |
|---|---|---|
app.scope(name, setup) |
The app | Registers a scope; chainable |
app.store(name, data, config?) |
The store | Creates a store; see Stores |
app.start() |
Scans and binds; a second call warns and does nothing | |
app.destroy() |
Unbinds everything, fires rz:app:destroy, and ends every listener and request. Terminal |
|
app.fetch(url, options?) |
Promise<{ data, error, status, headers, response, config }>; never rejects |
Sends a request through the app's config and interceptors |
app.on([target,] event, callback, options?) |
A teardown function | Listens until the app is destroyed or options.signal aborts |
app.place(target, html, options?) |
true if anything was placed |
Inserts HTML the way rz-place does |
app.sse(url, options?) |
A close function | Opens a stream, or joins one already open at that URL |
app.interceptor(phase, fn, options?) |
A teardown function | Adds an interceptor; phase is 'request', 'response', or 'error' |
app.stores |
The store manager | See Store manager methods |
app.root |
The root element | Resolved from config.root at creation. Every event bubbles to it, and app.on listens on it by default |
app.config |
The resolved config | Read-only after creation. Per-request options go in rz-request, app.fetch options, or a request interceptor |
app.isReady |
true once rz:app:ready has fired |
false until the frame after the first scan. Once it's true, a ready trigger fires immediately |
app.fetchoptions:method,body,headers,params,timeout,abortKey,signal,indicator,triggerEl,skipInterceptors, and any otherRequestInitfield. WithouttriggerElit reads nothing from the DOM and routes nothing (unless the server sendsRouse-Place/Rouse-Deposit).app.ontarget defaults toapp.root. The event is one name or a one-key trigger-source descriptor; modifiers go in options, never in the name.media-[(min-width: 640px)]|onceisapp.on({ media: '(min-width: 640px)' }, fn, { once: true });edit-[@cart.items]is{ edit: 'cart.items' }(no@).app.placetarget is an element or a selector within the app root (every match). Positions, case-sensitive:innerHTML(default),outerHTML,beforebegin,afterbegin,beforeend,afterend,delete.app.sseoptions:triggerEl,withCredentials,signal.
Package exports
Named exports from rousejs.
| Export | Returns | Notes |
|---|---|---|
rouse(config?) |
An app (RouseApp) |
Also the default export. One app per root |
signal(value) |
A signal: s() reads, s(v) writes |
One reactive value. Use it for scope state, exposed through a getter |
computed(fn) |
A cached derived value, read as c() |
Recomputes only after a value fn read changes. Keep fn pure: no writes or side effects |
effect(fn) |
A stop function | Runs fn now and whenever what it read changes. Created in a scope setup, it stops with the scope |
reactive(object) |
A deeply reactive proxy of the object or array | Stores are already reactive; a scope's returned object isn't. Mutate the proxy in place |
dispatch(el, name, detail?, init?) |
The CustomEvent |
Dispatches a bubbling event from el. Use it to signal between nested scopes |
createKey(prefix?) |
A key unique for the page's lifetime | For rz-key on client-created items. Assign it once at creation, never in a getter |
Interceptor phases
Interceptors run on every fetch, push, and pull the app makes, in registration order, and may be async. skipInterceptors: true in app.fetch opts one call out; markup can't. rz-sse doesn't use them. Each app.interceptor call returns a teardown and takes { signal }.
| Phase | Function | Runs |
|---|---|---|
request |
(config) => config |
After :config listeners, before sending. The return is merged into the config; a throw fails the request through the error phase |
response |
(data, response, config) => data |
On a 2xx response, before routing. The return replaces data |
error |
(error, config) => error |
On any failure, including HTTP errors, timeouts, and cancellations, after the retry. The return replaces error |
TypeScript
Rouse ships its own types. The JavaScript API is typed end to end; markup is not: directive values are strings, so nothing checks data-rz-text="usr.name" against the scope.
import type { LifecycleEventMap, RenderHandlerCtx, ScopeSetup } from 'rousejs';
type Todo = { id: string; title: string };
// The type argument types host
const todos: ScopeSetup<HTMLUListElement> = ({ host }) => ({
get count() { return host.children.length; },
// Inside rz-render, render.item is the row's Todo
remove({ render }: RenderHandlerCtx<Todo>) { /* render.item.id */ },
});
// app.on infers the detail from the event name
app.on('rz:fetch:error', (e) => console.warn(e.detail.status));
// Elsewhere, index LifecycleEventMap
el.addEventListener('rz:store:patch', (e) => {
const { storeName } = (e as CustomEvent<LifecycleEventMap['rz:store:patch']>).detail;
});
Exported types
Import these with import type { … } from 'rousejs'.
| Type | For |
|---|---|
RouseConfig |
Options for rouse() |
ScopeSetup<E> |
A setup function; E types host |
ScopeCtx<E> |
The setup's argument: { host, app, term, scan } |
HandlerCtx<E> |
A handler's argument: { el, e, render } |
RenderHandlerCtx<Item, E> |
A handler's argument inside rz-render; render.item is Item |
BindableValue |
What a binding can write |
LifecycleEvent |
Union of every lifecycle event name |
LifecycleEventMap |
Lifecycle event name → detail type |
app.store('cart', data)returnsdata's type, so the store's fields, getters, and methods are typed.app.stores.get<T>(name)takes the type explicitly.dispatch(el, name, detail)checksdetailwhennameis a lifecycle event.
Lifecycle events
Rouse dispatches rz:* events as an app, scope, request, store, or stream changes state. Listen with app.on or use one as a trigger in markup.
// Report failed requests from anywhere in the app
app.on('rz:fetch:error', (e) => toast(`Request failed: ${e.detail.status}`));
// Add a header to fetches from one form; config is the object that gets sent
app.on(form, 'rz:fetch:config', (e) => {
e.detail.config.headers['X-Form'] = form.id;
});
// Cancelable: veto an HTML placement
app.on('rz:dom:place:before', (e) => {
if (!e.detail.payload.trim()) e.preventDefault();
});
Every event bubbles to app.root. Each fires from the element it describes:
- App from
app.root; scope from the scope's host. - Request (a network operation) from the triggering element, or
app.rootwithout one.rz:push:*andrz:pull:*have the same events asrz:fetch:*, minus the per-content-type ones. - Destination (what a response changed) from the store's script (else
app.root) or the placement target (its parent forouterHTML/delete).operationorsourcenames the cause. - Connection (a stream) from the
rz-sseelement.
All lifecycle events
Every rz:* event, when it fires, and its detail.
| Event | When | Detail |
|---|---|---|
rz:app:start |
start() begins, before the scan |
{ app } |
rz:app:ready |
The frame after the first scan | { app } |
rz:app:destroy |
destroy() |
{ app } |
rz:scope:init |
Setup returned | { context, instance } |
rz:scope:connect |
Bindings attached, after rz-wake |
{ instance } |
rz:scope:disconnect |
Bindings detached | { instance } |
rz:scope:destroy |
Torn down; fires last. instance is for recognizing the scope, not calling it |
{ instance } |
rz:fetch:config |
Before sending. Cancelable: prevented, nothing else fires. Edit config in place (absolute url, method, headers); it's the object sent |
{ config }; push/pull add storeName |
rz:fetch:start |
Sent | as :config |
rz:fetch:success |
2xx response. Exactly one of :success, :error, :abort fires |
Fetch: the response { data, error, status, headers, response, config }. Push/pull: { storeName, result } |
rz:fetch:success:{html,json,file} |
Right after :success, per content type; what rz-place and rz-deposit route |
the response |
rz:fetch:error |
Error status, network failure, or timeout | as :success |
rz:fetch:error:{html,json,file} |
Right after :error, per content type |
the response |
rz:fetch:abort |
Aborted by a newer request or a signal | as :config |
rz:fetch:end |
Always last | as :config |
rz:store:patch:before |
Before a payload is applied. Cancelable; payload can be rewritten |
{ storeName, operation, data, payload } |
rz:store:patch |
Payload applied | { storeName, operation, data, payload, response } |
rz:store:patch:skipped |
An edit made during the request kept the response from applying. A deposit never skips | { storeName, operation, localData, serverData, response } |
rz:dom:place:before |
Before placing. Cancelable; payload can be rewritten |
{ target, position, payload, source } |
rz:dom:place |
Placed | { target, position, payload, source } |
rz:sse:config |
Before the first connection, once. Cancelable; edit config in place |
{ config } |
rz:sse:open |
Each successful (re)connection | { config } |
rz:sse:message:{html,json} |
Unnamed messages, before :message; what rz-place and rz-deposit route |
as :message |
rz:sse:message |
Every message | { config, event, data, raw, lastEventId } |
rz:sse:error |
Connection lost. Cancelable: prevented, no reconnect | { config, attempt } |
rz:sse:close |
Stream closed | { config, reason } |
- Request order is always
:config→:start→ one of:success/:error/:abort→:end, and the request's outcome fires before any destination event (rz:push:successbeforerz:store:patch:before). - Destination events fire only when something changed, so failures appear only on the request events.
- Only real network operations fire request events.
update(),reset(), and triggers dropped by a guard (disconnected, disabled, a sync already running, a write in flight) fire nothing. - A stream has no final event per message and no
:end: it opens once and receives many messages.
Multiple apps
Each rouse({ root }) is self-contained: its own stores, scopes, interceptors, listeners, and config, so @cart in one app never reaches another app's cart. Use separate apps for micro-frontends or widgets that must stay isolated. One app per root; after destroy(), a new app can mount on the same element.
// The page app owns the whole document
const page = rouse();
page.start();
// Later, a widget with its own app. Constructing it first marks the root,
// so the page app skips the widget's markup when it's inserted
const root = document.createElement('div');
const widget = rouse({ root, baseUrl: '/widget-api' });
widget.store('cart', { items: [] }); // a different cart from the page's @cart
document.querySelector('#sidebar').append(root);
widget.start();
- Apps can nest. A page app on the default
document.documentElementwith an embedded widget is nested. The outer app skips the inner app's markup, and a nested app mounted after the outer one started takes its markup over. - Construct a nested app before inserting its root element, or the outer app binds that markup until the nested app exists.
- Events bubble across roots: an outer
app.on('rz:fetch:success', …)orsse-[name]|rootalso hears the inner app's. To handle only your own app's events, checke.target.closest('[data-rouse-app]') === app.root.
Silent pitfalls
These fail without an error, at most a dev warning. Most do nothing; some do the wrong thing.
- Modifiers need a pipe.
click.debounce-[300ms]is one event namedclick.debounceand never fires. Writeclick|debounce-[300ms]. - Render fields need a dot.
%nameis not%.name. The dot-less form readsundefinedand writes nothing. - Key names are exact.
key-[entr]never fires, andkeydown|enter(nokey-[]) fires on every key, with only a dev warning.key-[ctrl]is wrong: the key name iscontrol, the held flag isctrl. - Scope state isn't reactive by default. A plain field or
letreturned from setup renders once and never updates; use asignalbehind a getter, or return areactiveobject directly. - Scope listeners and requests are app-wide.
app.onin a scope listens on the whole app unless you passhostfirst, and lasts as long as the app withoutsignal: term.app.fetchlikewise keeps running after teardown withoutsignal: term. - Register before scanning. A store registered after the markup reading it was scanned stays
undefinedin those bindings for good. - Stream events stay local.
sse-[name]hears only its own element's stream and descendants. A listener beside the stream needssse-[name]|root. - Some events fire off the element.
visibilitychangefires ondocument: writevisibilitychange|document.load|windownever fires if the app starts after load; useready. - Network directives don't wait for a lazy scope.
rz-fetch,rz-pull,rz-push, andrz-sseinside a scope withrz-wakestart at page load. Use thewaketrigger:data-rz-sse="wake: /events". - Wake waits for every trigger.
rz-wakeis AND:interact timeout-[5s]waits for both. - Wake and close need a host directive.
rz-wakedoes nothing withoutrz-scope, andrz-closenothing withoutrz-sse. - Null deletes synced fields. In a synced store
nulldeletes the key, so leave a field absent instead ofnull. A server serializer that omits empty values (Goomitempty) can never clear a field. - Placement defaults to the host.
rz-placedefaults to the host'sinnerHTML, so a stream element without it writes every unnamed HTML message into itself. Usedata-rz-place="null"to opt out. - Edit triggers watch data fields.
edit-[@store.key]filters on data fields. A setter writes its underlying field, so watching the accessor's key never fires. - CORS must allow Rouse headers. A cross-origin server must list the
Rouse-*headers inAccess-Control-Allow-Headers(or sync requests fail asFailed to fetch) andAccess-Control-Expose-Headers(or response headers are ignored). - Push isn't plain JSON. Push bodies are
application/merge-patch+json. A server parsing onlyapplication/json(Expressexpress.json()) sees an empty body. - Streams send no headers.
EventSourcecan't set headers, sorz-headers,rz-request, and interceptors don't apply torz-sse. - URLs can read as methods. A relative URL spelled like an HTTP method is read as one: write
./query, notquery. - Scripts in placed HTML don't run.
rz-place,Rouse-Place,rz-sse,rz-html, andapp.placeinsert HTML throughinnerHTML, which never runs<script>. Put behavior in a scope; the fragment's directives still bind.