DEV Community

Vy Quốc Vũ
Vy Quốc Vũ

Posted on

I Built Rillio: A JSX UI Runtime Using Addressable DOM Instead of Virtual DOM

I've been working on a side project called Rillio, a small TypeScript/JSX UI runtime.

GitHub: https://github.com/vyquocvu/rillio

It started as an experiment. I was curious about one question:

What if DOM nodes had stable identities, and we could update them directly without relying on a Virtual DOM tree as the main source of truth?

React and Preact already solve UI rendering really well. SolidJS also showed that fine-grained reactivity can work without a traditional Virtual DOM.

So I'm not trying to reinvent React or claim I've built something faster than everything else.

I just wanted to explore a different architecture.

The idea: Addressable DOM / Flat Index

In Rillio, every managed DOM node gets a stable NodeId.

Instead of maintaining another authoritative component/VDOM tree describing the DOM structure, Rillio keeps a flat registry of nodes and their relationships.

A simplified version looks like this:

interface FlatNode {
  id: number;
  parentId: number | null;
  prevSibling: number | null;
  nextSibling: number | null;
  firstChild: number | null;
  lastChild: number | null;
  version: number;
  node: Node;
}
Enter fullscreen mode Exit fullscreen mode

The actual implementation has more fields for ownership, lifecycle, boundaries and other things.

The important part is that a node can be accessed directly by ID.

const record = registry.getNode(nodeId);
Enter fullscreen mode Exit fullscreen mode

No need to walk through a component tree just to locate the record.

Of course, looking up a node is only one small part of rendering. Structural changes, lifecycle management and keeping the registry in sync with the browser DOM are much harder problems.

One Patch Engine for DOM updates

Another design decision was to keep DOM mutations behind a single Patch Engine.

Something like:

tx.setText(nodeId, "Hello");
tx.move(nodeId, parentId, beforeId);
tx.remove(nodeId);
Enter fullscreen mode Exit fullscreen mode

The runtime validates operations and applies them through a shared mutation path.

This gives Rillio a place to manage node ownership, ordering, versioning and cleanup.

It also makes debugging more interesting, since DOM changes can be described as operations rather than just arbitrary mutations scattered across the runtime.

But there is a tradeoff: transactions introduce their own complexity, especially around rollback, nested updates and callbacks.

I'm still working on those edge cases.

JSX stays familiar

I didn't want developers to learn a completely new syntax.

You can write components like this:

import { createRoot, useState } from "rillio";

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(n => n + 1)}>
      Count: {count}
    </button>
  );
}

createRoot(document.getElementById("app")!)
  .render(<Counter />);
Enter fullscreen mode Exit fullscreen mode

Looks pretty familiar, right?

But under the hood, Rillio uses its Flat Index and Patch Engine to manage DOM identity and updates.

It also has an explicit reactive DOM API for cases where I want more direct control over updates.

One thing worth mentioning: React-like syntax doesn't mean full React compatibility. That's not the goal right now.

Some interesting algorithm problems

This project turned out to involve more algorithms than I expected.

A few examples:

1. Longest Increasing Subsequence (LIS)

When reordering keyed lists, we want to preserve existing DOM nodes and avoid unnecessary moves.

LIS helps determine which nodes can stay in place.

For example:

Old: A B C D E
New: B D A C E
Enter fullscreen mode Exit fullscreen mode

Instead of recreating the entire list, we can preserve the DOM nodes and move only the ones that need to change position.

2. Doubly linked siblings

Instead of storing child relationships only in arrays, the Flat Index tracks previous and next siblings.

This allows efficient linking and unlinking when the relevant nodes are already known.

3. Transaction validation

Before changing DOM structure, the runtime needs to check whether operations are valid.

For example, moving a parent into its own descendant should never be allowed.

4. Reactive dependency graphs

When state changes, ideally only the affected computations should run.

Things get tricky when dependencies change dynamically or multiple computations share the same dependencies.

5. Randomized consistency testing

This is probably one of my favorite parts.

I generate sequences of DOM updates and check whether the Flat Index still agrees with the actual DOM.

It's basically algorithm practice, except a bug might make half the UI disappear :)

What about performance?

I've been benchmarking Rillio against other UI runtimes, especially for keyed lists, text updates and DOM reordering.

Some scenarios look promising, particularly updates that benefit from stable node identity and minimal moves.

But there are also costs.

Maintaining a Flat Index requires additional metadata, and initial mounting has its own overhead.

So I don't think a single benchmark is enough to say one architecture is better.

I'm more interested in understanding which workloads benefit from this design, and which don't.

What's next?

Right now I'm focusing on correctness before adding more features.

A few things I want to improve:

  • Stronger transaction validation and rollback behavior
  • More randomized and model-based testing
  • Better handling of deeply nested DOM structures
  • Exploring cost-aware DOM patch planning
  • Investigating whether a compiler could optimize JSX into more direct DOM bindings

The long-term idea is to keep Addressable DOM as the foundation, while everything else — JSX, components, state and reactivity — works around it.

Final thoughts

Building a UI runtime from scratch has made me appreciate how many difficult problems existing frameworks already solve.

A simple counter is easy.

Keyed lists, lifecycle cleanup, focus preservation, nested updates, error recovery and keeping everything consistent? Not so easy.

I'm still not sure whether Addressable DOM will turn out to be a better architecture for real-world applications.

But thats exactly why I'm building it.

If you've worked on rendering engines, reactive systems or frontend frameworks, I'd really appreciate your thoughts.

Is a Flat Index actually a useful abstraction for UI rendering, or am I just moving complexity from one place to another?

Source code: https://github.com/vyquocvu/rillio

GitHub logo vyquocvu / rillio

Rillio is an experimental addressable-DOM runtime. It preserves familiar JSX authoring, while DOM identity, structure, ownership, and lifecycle are owned by a Flat Index rather than by a Virtual DOM tree.

Rillio

npm version

Rillio is an experimental addressable-DOM runtime for client-side apps. JSX and explicit reactive views share one Flat Index and one transactional PatchEngine.

Rillio Studio workspace dashboard rendered with Rillio

Rillio Studio dashboard built with the Rillio runtime.

🚀 Quick start

Install the published package in an existing app:

npm install @vyquocvu/rillio
Enter fullscreen mode Exit fullscreen mode

See the package on npm for published versions and package details, or check out the standalone quickstart project.

Or clone the repository to run the examples locally:

git clone https://github.com/vyquocvu/rillio.git
cd rillio
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

Open http://127.0.0.1:5173/ for the Rillio landing page, then follow its links to the showcases and docs.

⚛️ JSX runtime

import { createRoot, useState } from "@vyquocvu/rillio";
function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(value => value + 1)}>{count
…
Enter fullscreen mode Exit fullscreen mode

Top comments (0)