Skip to main content

DOM Renderer

The @exodra/dom package is Exodra's browser renderer. It turns raw Exodra schemas — produced by h() (from @exodra/core) or compiled from JSX by @exodra/babel-plugin-jsx — into live DOM, and reconciles reactive updates in place using identity-based reconciliation. It also hydrates server-rendered HTML so a page produced with the SSR tooling becomes interactive without re-creating the DOM.

Exodra has a strict bucketed props model — attributes (static), reactive values (bindables), reactive lists (bindableLists), and event handlers (handlers, bindableHandlers) each live in their own bucket. There are no flat React-style props.

The raw h() buckets are plural (static, bindables, bindableLists, handlers, bindableHandlers). In JSX they are written singular (static, bindable, bindableList, handlers, bindableHandlers) and the babel plugin maps them to the plural core buckets.

npm install @exodra/dom @exodra/core @exodra/reactivity

Exports: mount, hydrate, ExoNodeDom, and the TExoDomMountResult type.

mount()

Renders a schema and appends its element into a container. Returns a TExoDomMountResult.

function mount(schema, container): TExoDomMountResult;

type TExoDomMountResult = {
node: ExoNodeDom; // the live node tree
element: Element | Text; // the mounted root (the container for a Fragment root)
dispose(): void; // unmount + detach from the DOM
};
import { h, text } from '@exodra/core';
import { mount } from '@exodra/dom';

const app = h('div', {
static: {
class: 'app',
children: [
h('h1', { static: { children: text('Hello Exodra') } }),
h('button', {
static: { children: text('Click me') },
handlers: { onClick: () => alert('Clicked!') },
}),
],
},
});

const { dispose } = mount(app, document.getElementById('root'));

// Later, unmount and detach from the DOM:
dispose();

There is no standalone unmount(container), and mount does not return a function. To unmount, keep the result and call result.dispose().

The 3rd argument to h(type, attrs, cacheKey) is a clone-cache key, not children. Children always live inside a bucket — static.children (static), bindables.children (a single reactive child), or bindableLists.children (a reactive list). If specified in more than one bucket, the last wins.

Event handling

Event handlers go in the handlers bucket. A flat onClick would silently land in static as a dead attribute, so Exodra keeps them separate. For a reactive handler (a bindable whose value is the current listener), use the bindableHandlers bucket.

h('button', {
static: { children: text('Interactive') },
handlers: {
onClick: e => console.log('Clicked!', e),
onMouseOver: () => console.log('Hover!'),
},
});

Reactive attributes

Reactive values are bindables/derived from @exodra/reactivity, passed directly into the bindables bucket — they are bindable objects, not thunks. Read with .getValue(), write with .setValue(...). There is no .value and no .get()/.set().

import { bindable, derive } from '@exodra/reactivity';

const isActive = bindable(false);
const cls = derive(isActive, active => (active ? 'active' : 'inactive'));

h('div', {
bindables: { class: cls },
handlers: { onClick: () => isActive.setValue(!isActive.getValue()) },
});

Conditional rendering

Use a derived bindable as a single reactive child via bindables.children:

import { bindable, derive } from '@exodra/reactivity';
import { h, text } from '@exodra/core';

const showDetails = bindable(false);
const details = derive(showDetails, show =>
show ? h('div', { static: { children: text('Details…') } }) : text('')
);

h('div', {
bindables: { children: details },
});

List rendering

Reactive lists come from list() and go into the bindableLists bucket. The renderer reconciles them by identity, touching only the nodes that changed.

import { list } from '@exodra/reactivity';
import { h, text } from '@exodra/core';

const rows = list([
h('li', { static: { children: text('Item 1') } }),
h('li', { static: { children: text('Item 2') } }),
]);

const ul = h('ul', {
bindableLists: { children: rows },
});

// Mutate with list ops — push / insert / remove / move / set / reset:
rows.push(h('li', { static: { children: text('Item 3') } }));

See the Lists & Reconciliation guide for how keyed, op-based reconciliation works.

hydrate()

For SSR apps, hydrate() adopts the existing server-rendered element instead of recreating it. It has the same signature and return type as mount().

function hydrate(schema, element): TExoDomMountResult;
import { hydrate } from '@exodra/dom';

const { dispose } = hydrate(app, document.getElementById('root'));

Use mount() for a fresh client-only render, and hydrate() when the markup already exists from the server. See the SSR guide for the full server-render + hydration flow.

ExoNodeDom

ExoNodeDom is the DOM node implementation — the live node tree that backs a mounted schema. It is exposed on the mount result as result.node. Most apps never construct it directly; it is available for advanced integrations and for typing.

Lifecycle hooks

Static lifecycle hooks onExoMount / onExoUnmount live in the static bucket and receive the node ({ element }):

h('div', {
static: {
onExoMount: node => console.log('mounted', node.element),
onExoUnmount: node => console.log('unmounted', node.element),
},
});

API Reference

ExportDescription
mount(schema, container)Mount a schema; returns { node, element, dispose }.
hydrate(schema, element)Hydrate existing DOM; returns { node, element, dispose }.
ExoNodeDomThe DOM node implementation.
TExoDomMountResult (type)Return type of mount / hydrate.