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.

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

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 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 })
The pattern In all three cases: signals are the source of truth, the server (or storage) provides initial values, and bind() wires the already-correct state to the DOM.