Flight Manual
Complete reference for every API, directive, and pattern in rdbl. Start at the top — the whole library builds on three primitives.
Signals
The entire reactive model. A signal holds a value and notifies observers when it changes.
import { signal } from 'rdbl'
const count = signal(0)
count() // → 0 read (tracks dependencies)
count.set(5) // write
count.peek() // → 5 read WITHOUT tracking
.peek()?
Use it when you need the current value inside a computed or effect but don't want that signal to be a dependency. Common when writing a value back to itself.
Computed
Derived state. Lazy — only re-evaluates when its dependencies change. Cached between reads.
import { signal, computed } from 'rdbl'
const first = signal('Neil')
const last = signal('Armstrong')
const full = computed(() => `${first()} ${last()}`)
full() // → 'Neil Armstrong'
first.set('Buzz')
full() // → 'Buzz Armstrong' (re-evaluated)
Effect
Runs immediately on creation, then re-runs when any dependency changes. Returns an optional cleanup function.
import { signal, effect } from 'rdbl'
const status = signal('STANDBY')
effect(() => {
console.log('Status is now:', status())
return () => console.log('cleanup') // optional teardown
})
Batch
Defer effect execution until all mutations are complete. Multiple signal updates coalesce into one re-render.
import { signal, batch } from 'rdbl'
const x = signal(1), y = signal(2)
batch(() => {
x.set(10)
y.set(20)
// effects fire once, after this block
})
Directives
Plain HTML attributes — no special syntax, no magic prefixes. A directive value is a dot-separated path into your scope.
text="path"
Sets textContent. Safe — no HTML injection.
<span text="user.name"></span>
<p text="summary"></p>
html="path"
Sets innerHTML. Use for trusted rich content.
<div html="article.body"></div>
show="path"
Toggles the hidden attribute. On <dialog> elements, calls showModal() / close() automatically.
<div show="isVisible">Revealed when true</div>
<dialog show="modalOpen">A native modal</dialog>
cls="path"
String form replaces className. Object form toggles individual classes by truthy value.
<div cls="statusClass"></div>
// String form
const statusClass = computed(() => isActive() ? 'active' : 'inactive')
// Object form — toggles individual classes
const statusClass = computed(() => ({
active: isActive(),
disabled: isDisabled(),
loading: isFetching(),
}))
attr="name:path; name2:path2"
Sets arbitrary element attributes. Removes the attribute when the value is false or null.
<button attr="aria-label:label; disabled:isDisabled; data-id:item.id">
Click me
</button>
model="path"
Two-way binding for form elements. The path must resolve to a signal — model writes back to it on change.
<input model="query">
<input type="checkbox" model="isChecked">
<select model="selectedOption"></select>
<textarea model="notes"></textarea>
You typed: "" — length:
each="path" key="idPath"
Keyed list rendering. Requires a <template> child with the item markup. Efficiently diffs and reorders DOM nodes using the key.
Inside list items, the scope includes all item properties, plus $item (the full item object) and $index (zero-based position).
<ul each="todos" key="id">
<template>
<li>
<input type="checkbox" model="done">
<span text="text"></span>
<button onclick="remove">✗</button>
</li>
</template>
</ul>
on<event>="path"
Event binding. The path resolves to a function in your scope. The handler receives (event, element, context). Return false to call both preventDefault() and stopPropagation().
<button onclick="handleClick">Launch</button>
<form onsubmit="handleSubmit"></form>
<input oninput="liveSearch">
const state = {
handleClick(event, el, ctx) {
// event → the DOM event
// el → the element
// ctx → Context.read(el)
},
handleSubmit(event) {
return false // preventDefault + stopPropagation
}
}
bind()
const app = bind(rootElement, scope, options)
Walks the DOM from rootElement, wiring up all directive attributes it finds to the given scope. Returns an app instance.
Options
| Option | Default | Description |
|---|---|---|
dev | true | Log warnings for missing paths, bad values, missing keys |
autoBind | false | Use MutationObserver to auto-bind dynamically added DOM |
ignoreSelector | [data-no-bind] | CSS selector to opt subtrees out of binding |
app.bindSubtree(element, scope)
Bind a sub-element with its own scope. The sub-scope falls back to the parent scope for unresolved paths.
const app = bind(root, parentScope)
app.bindSubtree(cardElement, cardScope)
app.dispose()
Tear down all effects and event listeners created by this binding. Essential for cleanup when removing DOM.
app.dispose()
getItemContext()
Inside each list event handlers, use this to access the item data for the row that was interacted with. Walk up any depth — rdbl attaches context to the nearest list item ancestor.
import { signal, getItemContext } from 'rdbl'
const todos = signal([{ id: 1, text: 'Launch rocket', done: false }])
const state = {
todos,
toggleTodo(event, el) {
const { item, index } = getItemContext(el)
todos.set(
todos().map(t => t.id === item.id ? { ...t, done: !t.done } : t)
)
}
}
createScope()
Create a child scope that falls back to a parent for unresolved properties. Useful for composing nested views.
import { createScope } from 'rdbl'
const child = createScope(parentScope, {
localTitle: signal('Local Override'),
// everything else falls through to parentScope
})
bind(element, child)
Context
DOM-scoped context — looked up by walking the element tree. Avoids prop drilling for shared services like routers, loggers, or stores.
import { Context } from 'rdbl'
// Provide at any ancestor element
Context.provide(document.querySelector('#app'), {
router: { go: path => history.pushState({}, '', path) },
log: console.log,
})
// Read from any descendant (walks up to find nearest provider)
const ctx = Context.read(someChildElement)
// Available as 3rd argument in all event handlers
function handleNav(event, el, ctx) {
ctx.router.go('/dashboard')
}
Islands
Islands are independently bound components. Each element with an [island] attribute is its own binding root —
rdbl stops traversal at nested island boundaries so parent and child islands never interfere.
The island attribute value is a module path. The module's default export is a factory function.
Island module
// /components/SyncStatus.js
import { computed, Context } from 'rdbl'
export default function syncStatus(root, window) {
const { pageState } = Context.read(document.body)
return {
statusText: computed(() => pageState.connected() ? 'Synced' : 'Connecting...'),
indicatorCls: computed(() => pageState.connected() ? 'dot synced' : 'dot pending'),
}
}
Island HTML
<section island="/components/SyncStatus.js">
<span cls="indicatorCls"></span>
<span text="statusText"></span>
</section>
init() — binding all islands at startup
import { bind, Context } from 'rdbl'
async function init(window) {
const roots = [...document.querySelectorAll('[island]')]
const instances = {}
let i = 0
for await (const root of roots) {
const key = root.getAttribute('island')
try {
const scopeFactory = (await import(key)).default
const scope = scopeFactory(root, window)
instances[`${key}:${i++}`] = bind(root, scope, { dev: true })
} catch (err) {
console.error(`Failed to load island "${key}":`, err)
}
}
return instances
}
Context.provide(document.body, { pageState })
const app = await init(window)
Server-Side Rendered Data
rdbl works naturally with server-rendered HTML. The key rule: initialize your signals with server data before calling bind().
On first run, effects write the same values the server already rendered — the DOM doesn't flicker.
Server Side Rendered Template
{{greeting}} is interpolated and the client side code binds after it renders.
<!-- Server renders with template engine -->
<div id="app" island="/components/Dashboard.js">
<h1 text="greeting">{{greeting}}</h1>
</div>
Embedded JSON in a script tag
<!-- Server renders this -->
<script type="application/json" id="page-data">
{ "user": { "name": "Joey", "plan": "pro" }, "posts": [] }
</script>
<div id="app" island="/components/Dashboard.js">
<h1 text="greeting"></h1>
</div>
// /components/Dashboard.js
import { signal, computed } from 'rdbl'
export default function dashboard(root, window) {
const raw = JSON.parse(document.getElementById('page-data').textContent)
const user = signal(raw.user)
const greeting = computed(() => `Hello, ${user().name}`)
return { user, greeting }
}
Data attributes on the island root
<section island="/components/UserBadge.js"
data-name="Joey"
data-plan="pro">
<span text="name"></span>
<span cls="badgeCls" text="plan"></span>
</section>
import { signal, computed } from 'rdbl'
export default function userBadge(root, window) {
const name = signal(root.dataset.name)
const plan = signal(root.dataset.plan)
const badgeCls = computed(() => `badge badge-${plan()}`)
return { name, plan, badgeCls }
}
Hydrating from localStorage
const theme = signal('light')
const tasks = signal([])
// Hydrate before bind()
const stored = localStorage.getItem('app-state')
if (stored) {
const snap = JSON.parse(stored)
theme.set(snap.theme ?? 'light')
tasks.set(snap.tasks ?? [])
}
// Persist on every change
effect(() => {
localStorage.setItem('app-state', JSON.stringify({
theme: theme(),
tasks: tasks(),
}))
})
bind(document.querySelector('#app'), { theme, tasks })
bind() wires the already-correct state to the DOM.