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
Why .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.

If the element already has text content when bind() is called, rdbl preserves it on the first render. Once the signal changes, the DOM updates normally. This means server-rendered text requires no special attributes — it just works.

<!-- Server-rendered: "Dev Hub" is preserved on bind, then updates reactively -->
<span text="name">Dev Hub</span>

<!-- Client-only: starts empty, signal sets the value immediately -->
<span text="user.name"></span>

html="path"

Sets innerHTML. Use for trusted rich content. Same auto-preservation behavior as text — existing content is kept on first bind.

<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>
Live Telemetry — model binding

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>

SSR-rendered lists — data-key

For server-rendered lists, add data-key on each row with its item key value. rdbl adopts those DOM nodes into the live map on bind — no replacement on first render. The <template> is used only when new items are added after bind.

<ul each="products" key="id">
  <li data-key="1" text="name">Widget A</li>
  <li data-key="2" text="name">Widget B</li>
  <template><li text="name"></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

OptionDefaultDescription
devtrueLog warnings for missing paths, bad values, missing keys
autoBindfalseUse 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 Rendering

rdbl is designed for server-rendered HTML. Plain server-rendered content requires no special attributes — the library detects it automatically.

Text and HTML — auto-preserved

If a text or html element has content when bind() is called, rdbl skips the first render and leaves the server-rendered content in place. Once the signal changes, reactive updates apply normally.

<!-- No special attributes — content preserved automatically on bind -->
<div id="app" island="/components/Dashboard.js">
  <h1 text="greeting">Hello, Joey</h1>
  <p  text="summary">3 of 10 tasks complete</p>
</div>
// Initialize signals to match server-rendered values
export default function dashboard(root, window) {
  const greeting = signal('Hello, Joey')
  const summary  = signal('3 of 10 tasks complete')
  return { greeting, summary }
}

Lists — data-key

For server-rendered lists, add data-key on each row matching its item key value. rdbl adopts those DOM nodes into its live map on bind — the existing rows stay in place and become reactive. The <template> is used only when new items are added after bind.

<ul each="products" key="id">
  <li data-key="1" text="name">Widget A</li>
  <li data-key="2" text="name">Widget B</li>
  <template><li text="name"></li></template>
</ul>

Passing data from server to client

Embedded JSON — embed state as JSON, read it before binding:

<!-- Server renders -->
<script type="application/json" id="page-data">
  { "user": { "name": "Joey", "plan": "pro" } }
</script>

<div id="app" island="/components/Dashboard.js">
  <h1 text="greeting">Hello, Joey</h1>
</div>
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 — for smaller per-component values:

<section island="/components/UserBadge.js"
         data-name="Joey"
         data-plan="pro">
  <span text="name">Joey</span>
  <span cls="badgeCls" text="plan">pro</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 }
}

data-ssr

For show, attr, cls, and model directives there is no content to inspect, so auto-detection doesn't apply. Use data-ssr on those elements to skip the first render when you can't initialize the signal to match the server-rendered state.

<div  show="panelOpen" data-ssr>...</div>
<a    attr="href:url"   data-ssr>Read more</a>

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 })
The pattern Signals are the source of truth. The server (or storage) provides initial values. bind() wires the already-correct state to the DOM — plain server-rendered HTML, no special attributes required.