npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@devchitchat/rdbljs

v2.0.0

Published

Signals-based reactive DOM binding. Plain HTML, no build step, no virtual DOM.

Readme

rdbl

Reactive DOM Binding Library — pronounced "riddle"

Signals-based reactivity wired directly to plain HTML attributes. No build step. No virtual DOM. No template expressions. Just readable markup and a single file.

<div text="user.name"></div>
<input model="query">
<ul each="results" key="id">
  <template><li text="title"></li></template>
</ul>
import { bind, signal, computed } from 'rdbl'

const state = {
  user: { name: signal('Joey') },
  query: signal(''),
  results: signal([])
}

bind(document.querySelector('#app'), state)

That's it.


Why rdbl?

Most reactive libraries ask you to learn a new template language or move your logic into your markup. rdbl goes the other way: your HTML stays plain and readable, your JS stays in JS.

  • No {{}} expressions — bindings are attribute paths, not code
  • No x- prefixes — plain attributes: text, show, model, each
  • No build step — single ES module, import and go
  • No virtual DOM — direct DOM updates, batched via microtask
  • Signals all the way downsignal, computed, effect are the entire reactive model

Install

bun add rdbl

Or copy the single file directly — rdbl has no dependencies.

import { bind, signal, computed, effect, batch, Context, getItemContext, createScope } from 'rdbl'

Core Concepts

Signals

A signal holds a value. Read it by calling it. Write it with .set().

const count = signal(0)

count()         // 0  — read
count.set(5)    // write
count.peek()    // 5  — read without tracking dependencies

Computed

Derived state. Lazy-evaluated and cached until dependencies change.

const double = computed(() => count() * 2)
double()  // 10

Effect

Runs immediately and re-runs whenever its dependencies change.

effect(() => {
  console.log('count is', count())
  return () => console.log('cleanup')  // optional
})

Batch

Defer effect execution until all mutations are complete.

batch(() => {
  a.set(1)
  b.set(2)
  // effects fire once, after batch
})

HTML Directives

Directives are plain HTML attributes. No special syntax — just a dot-separated path into your scope.

text="path"

Sets textContent. If the element already has text content when bind() is called, rdbl preserves it on the first render — no attribute required. Once the signal changes, the DOM updates normally.

<!-- 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. Same auto-preservation behavior as text — existing content is kept on first bind.

<div html="article.body"></div>

show="path"

Toggles visibility via the hidden attribute. Works with <dialog> — calls showModal() / close() automatically.

<div show="isVisible"></div>
<dialog show="modalOpen">...</dialog>

cls="path"

Sets classes. Accepts a string (replaces className) or an object (toggles individual classes).

<div cls="statusClass"></div>
// string form
statusClass: computed(() => isActive() ? 'active' : 'inactive')

// object form
statusClass: computed(() => ({ active: isActive(), disabled: isDisabled() }))

attr="name:path; name2:path2"

Sets arbitrary attributes. Removes the attribute when value is false or null.

<button attr="aria-label:label; data-id:item.id"></button>

model="path"

Two-way binding for form elements. Path must resolve to a signal.

<input model="query">
<input type="checkbox" model="isChecked">
<select model="selectedTab"></select>
<textarea model="notes"></textarea>

each="path" key="idPath"

Keyed list rendering. Requires a <template> child for new items. Efficiently diffs and reorders DOM nodes.

<ul each="todos" key="id">
  <template>
    <li>
      <input type="checkbox" model="done">
      <span text="text"></span>
      <button onclick="removeTodo">x</button>
    </li>
  </template>
</ul>

Inside list items, the scope includes all item properties directly, plus $item and $index.

SSR-rendered lists: Add data-key on each server-rendered row matching the item's key value. rdbl adopts the existing DOM nodes into the live map — no replacement on first bind. The <template> is used only when new items are added.

<ul each="items" key="id">
  <li data-key="1" text="name">Alpha</li>
  <li data-key="2" text="name">Beta</li>
  <template><li text="name"></li></template>
</ul>
// Initialize scope with the same data the server used
const scope = { items: signal([{ id: 1, name: 'Alpha' }, { id: 2, name: 'Beta' }]) }
bind(document.querySelector('ul'), scope)

on<event>="path"

Event binding. Path resolves to a function in your scope.

<button onclick="handleClick">Submit</button>
<input oninput="handleInput">

The handler receives (event, element, context). Return false to call both preventDefault() and stopPropagation().

const state = {
  handleClick(event, el, ctx) {
    // do something
  }
}

List Items and getItemContext

Inside an each list, use getItemContext(element) in your event handlers to access the item data for the row that was interacted with.

import { getItemContext } from 'rdbl'

const state = {
  todos: signal([{ id: 1, text: 'write docs', done: false }]),

  toggleTodo(event, el) {
    const { item } = getItemContext(el)
    state.todos.set(
      state.todos().map(t => t.id === item.id ? { ...t, done: !t.done } : t)
    )
  }
}

Context

DOM-scoped context, looked up by walking the element tree. Avoids prop drilling for shared services like routers or loggers.

import { Context } from 'rdbl'

Context.provide(document.querySelector('#app'), {
  router: { go: path => history.pushState({}, '', path) },
  log: console.log
})

Context is available as the third argument in event handlers:

function handleNav(event, el, ctx) {
  ctx.router.go('/dashboard')
}

Read context directly anywhere you have an element:

const ctx = Context.read(someElement)

bind()

const app = bind(rootElement, scope, options)

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.

app.dispose()

createScope()

Create a child scope that falls back to a parent for unresolved properties.

import { createScope } from 'rdbl'

const child = createScope(parentScope, {
  title: signal('Local Title')
})

Full Example

<div id="app">
  <h1 text="title"></h1>
  <input model="newTodo" placeholder="Add a task...">
  <button onclick="addTodo">Add</button>

  <ul each="todos" key="id">
    <template>
      <li>
        <input type="checkbox" model="done">
        <span text="text" cls="itemClass"></span>
        <button onclick="remove">Delete</button>
      </li>
    </template>
  </ul>

  <p text="summary"></p>
</div>
import { bind, signal, computed, getItemContext } from 'rdbl'

const todos = signal([
  { id: 1, text: 'Read the docs', done: false },
  { id: 2, text: 'Build something', done: false }
])

const newTodo = signal('')

const state = {
  title: 'My Todos',
  todos,
  newTodo,
  summary: computed(() => {
    const all = todos()
    const done = all.filter(t => t.done).length
    return `${done} of ${all.length} done`
  }),

  addTodo() {
    const text = newTodo().trim()
    if (!text) return
    todos.set([...todos(), { id: Date.now(), text, done: false }])
    newTodo.set('')
  },

  remove(event, el) {
    const { item } = getItemContext(el)
    todos.set(todos().filter(t => t.id !== item.id))
  }
}

bind(document.querySelector('#app'), state, { dev: true })

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. Each island module exports a default factory function that receives the root element and window, reads shared state from Context, and returns its scope.

Island module

// /components/SyncStatus.js
import { computed, Context } from 'rdbl'

export default function syncStatus(root, window) {
  const { pageState } = Context.read(document.body)

  const statusText = computed(() =>
    pageState.connected() ? 'Synced' : 'Connecting...'
  )

  const indicatorCls = computed(() =>
    pageState.connected() ? 'dot synced' : 'dot pending'
  )

  return { statusText, indicatorCls }
}

Island HTML

<section island="/components/SyncStatus.js">
  <span cls="indicatorCls"></span>
  <span text="statusText"></span>
</section>

init() — binding all islands at startup

This pattern auto-discovers every [island] element, dynamically imports its module, and calls bind() with the returned scope.

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
}

// Provide shared state on the body so any island can read it
Context.provide(document.body, { pageState })

const app = await init(window)

Each island gets its own binding instance and can be disposed independently. Shared state lives in Context — islands read it without being passed props.


Server-Side Rendering

rdbl is designed to work with server-rendered HTML without flicker or replacement.

Text and HTML — auto-preserved

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

<!-- No special attributes needed — content is auto-preserved on bind -->
<h1 text="greeting">Hello, Joey</h1>
<p text="summary">3 of 10 tasks complete</p>
// Initialize signals to match what the server rendered
const scope = {
  greeting: signal('Hello, Joey'),
  summary: signal('3 of 10 tasks complete')
}
bind(document.querySelector('#app'), scope)

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 — existing nodes stay in place and become reactive. The <template> is used only for items 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>
const scope = {
  products: signal([
    { id: 1, name: 'Widget A' },
    { id: 2, name: 'Widget B' }
  ])
}
bind(document.querySelector('ul'), scope)

Passing data from server to client

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

<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>
// /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 — 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>
// /components/UserBadge.js
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 storage

import { bind, signal, effect } from 'rdbl'

const theme = signal('light')
const tasks = signal([])

const stored = localStorage.getItem('app-state')
if (stored) {
  const snap = JSON.parse(stored)
  theme.set(snap.theme ?? 'light')
  tasks.set(snap.tasks ?? [])
}

effect(() => {
  localStorage.setItem('app-state', JSON.stringify({ theme: theme(), tasks: tasks() }))
})

bind(document.querySelector('#app'), { theme, tasks })

Design Principles

  1. HTML is structure. Directives describe what to bind, not how.
  2. No expressions in markup. Paths only — logic lives in JS.
  3. Signals are the model. One reactive primitive, no magic objects.
  4. DOM-first. No virtual DOM, no diffing overhead beyond list keys.
  5. Single file. Copy it, own it. No build pipeline required.

Size

~4KB gzipped. Zero dependencies.


License

MIT