@esmj/dom
v0.4.1
Published
Tiny extendable package for DOM manipulation.
Readme
@esmj/dom
A tiny, reactive DOM library for building component-based UIs in vanilla JavaScript. It uses @esmj/signals for fine-grained reactivity — only the parts of the DOM that depend on a changed signal update, with no virtual DOM or diffing overhead.
Table of Contents
Installation
npm install @esmj/domQuick Start
import { createSignal } from '@esmj/signals';
import { createElement, mount, onMount } from '@esmj/dom';
function Counter() {
const count = createSignal(0);
onMount(() => {
console.log('Counter mounted');
return () => console.log('Counter unmounted');
});
return createElement('div', {}, [
createElement(
'button',
{ onClick: () => count.set(count.get() + 1) },
[() => `Count: ${count.get()}`],
),
]);
}
mount('#app', createElement(Counter, {}));Children that are functions (() => someSignal.get()) are reactive text nodes — they re-evaluate and update the DOM automatically whenever a signal they read changes.
Core Concepts
Signals as props — Any prop value can be a signal, a computed, or a plain function. createElement wraps plain functions in computed() automatically, so the attribute or text node stays in sync with the signal graph.
Prop name aliases — className → class, htmlFor → for, tabIndex → tabindex, readOnly → readonly, autoComplete → autocomplete.
Special props:
$ref: (el) => ...— called with the real DOM element after it is created.on*(e.g.onClick,onInput) — added asaddEventListenerlisteners (onSecurityPolicyViolationis blocked).style— accepts an object{ color: 'red' }or a string.$dangerouslySetInnerHTML— accepts a string,DocumentFragment, signal, or function and replaces element content reactively.- Security guardrails:
srcdocis blocked, and URL-like attributes (href,src,action,formaction,xlink:href) rejectjavascript:values.
Ownership model — If and For accept two kinds of children:
- Borrowed Node (
createElement('div', ...)) — only detached/re-attached on branch switch; reactive bindings and effects stay alive across toggles. - Owned ComponentInstance (
createElement(MyComponent, ...)) — fully torn down (onUnmountfires, effects disposed) and freshly created on each activation.
API Reference
mount(container, rootChild)
Renders rootChild into container and clears any previous content.
If the container already holds a previously mounted tree, mount() first runs
the same teardown path as unmount() for existing children (component
onUnmount hooks + registered disposers), then mounts the new root.
| Param | Type | Description |
|---|---|---|
| container | string \| Element | CSS selector string or a DOM element. |
| rootChild | Node \| ComponentInstance \| string | The root node or component to render. |
mount('#app', createElement(App, {}));
mount(document.getElementById('app'), createElement(App, {}));unmount(container)
Tears down the component tree inside container and clears its DOM. This is the symmetric inverse of mount.
Runs all onUnmount hooks and disposes signal effects (onEffect) for every component in the tree, then empties the container element.
| Param | Type | Description |
|---|---|---|
| container | string \| Element | CSS selector string or a DOM element. |
// Mount an app
mount('#app', createElement(App, {}));
// Later — tear it down cleanly
unmount('#app');createElement(tagName|Component, props, children)
Creates a DOM element or a lazy component descriptor.
Signatures:
// HTML element
createElement('div', { className: 'box', onClick: handler }, [child1, child2]);
// Function component
createElement(MyComponent, { count: signal });
// Shorthand (defaults to <div>)
createElement({ className: 'wrapper' }, [children]);Children can be:
- A DOM
Node - A component instance (result of
createElement(Component, props)) - A
stringornumber— becomes a text node - A
() => valuefunction — becomes a reactive text node; re-evaluated on signal change
$dangerouslySetInnerHTML also supports reactive updates:
const html = createSignal('<em>initial</em>');
createElement('div', {
$dangerouslySetInnerHTML: html, // also accepts () => '<b>...</b>' or DocumentFragment
});const count = createSignal(0);
createElement('p', {}, [
() => `Current count: ${count.get()}`,
]);Fragment & JSX support
@esmj/dom ships an automatic JSX runtime, so you can use .jsx/.tsx files with a transpiler (esbuild, Babel, or TypeScript) configured for the automatic JSX transform instead of calling createElement directly.
tsconfig.json:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@esmj/dom"
}
}Once configured, the transpiler rewrites JSX into calls against @esmj/dom/jsx-runtime (production) or @esmj/dom/jsx-dev-runtime (dev-time tooling such as Vite dev/Babel development: true) automatically — you don't import from those entry points yourself.
import { createSignal } from '@esmj/signals';
import { mount } from '@esmj/dom';
function Counter() {
const count = createSignal(0);
return (
<button onClick={() => count.set(count.get() + 1)}>
Count: {() => count.get()}
</button>
);
}
mount('#app', <Counter />);Fragment (<>...</>) groups children without introducing a wrapping DOM element — it renders as a DocumentFragment. It is also exported as a value from the main package for direct createElement(Fragment, props, children) calls.
import { Fragment } from '@esmj/dom';
function Pair() {
return (
<>
<span>left</span>
<span>right</span>
</>
);
}Known limitation: returning a
Fragmentas the sole root value of a component'srender()/function body works for mounting and unmounting (includingonMount/onUnmount/onEffect), but an empty Fragment (zero children) has no live DOM node left to carry that bookkeeping, so its lifecycle hooks will not fire. Always render at least one child from a Fragment-returning component.
Component base class
Extend Component to use the class component API. The library calls render() to build the DOM and looks for optional onMount() / onUnmount() lifecycle methods.
Props are signals. All props passed to a class component are normalized automatically: plain values become
createSignal(value), functions becomecomputed(fn). Always call.get()to read a prop value insiderender()and lifecycle methods.
import { Component, createElement } from '@esmj/dom';
class MyCard extends Component {
onMount() {
console.log('mounted, label:', this.props.label.get());
}
onUnmount() {
console.log('unmounted');
}
render() {
return createElement('div', { className: 'card' }, [
this.props.children,
]);
}
}
// Usage
createElement(MyCard, { label: 'Hello' }, [
createElement('p', {}, ['Hello from class component']),
]);If(condition, thenChild, elseChild?, options?)
Conditionally renders one of two branches. When the branch changes, the inactive branch is removed from the DOM (component instances are fully torn down; lifecycle hooks fire).
| | If | Show |
|---|---|---|
| DOM removal on toggle | ✅ full teardown | ❌ stays in DOM |
| onMount/onUnmount fire | ✅ | ❌ |
| Reactive attributes preserved | resets on each activation | ✅ always alive |
| Use when | branch is rarely shown or expensive to keep | state/scroll position must survive hide |
Use If when you need DOM teardown and lifecycle hooks. Use Show when you only want to toggle visibility while keeping the element alive.
| Param | Type | Description |
|---|---|---|
| condition | () => boolean | Reactive condition accessor. |
| thenChild | Node \| ComponentInstance | Rendered when condition is truthy. |
| elseChild | Node \| ComponentInstance | Optional. Rendered when condition is falsy. |
| options.tagName | string | Optional. Tag name for the wrapper element. Defaults to 'span'. Use this when the wrapper's parent element only accepts specific direct children (e.g. { tagName: 'tbody' } inside a <table>). If you want to pass options without an elseChild, pass null as the 3rd argument: If(cond, thenChild, null, { tagName: 'tbody' }). |
Returns the wrapper element (default <span>, configurable via options.tagName). Only the default/fallback <span> gets display:contents (invisible to CSS layout) — an explicitly chosen tagName keeps its normal display, since it's assumed to already be a valid element for its context (e.g. a real <tbody> doesn't need hiding to behave correctly inside a <table>).
Known limitation: no
tagNamevalue makes the wrapper valid inside<ul>,<ol>, or<select>— those elements only accept their specific item tag (<li>/<option>) as direct children. AvoidIf/Fordirectly inside those elements until a future anchor-based (no-wrapper) implementation lands.
const isLoggedIn = createSignal(false);
If(
() => isLoggedIn.get(),
createElement(Dashboard, {}),
createElement(LoginForm, {}),
);JSX usage — If also accepts a props object + children, so it can be used directly as a JSX element:
<If when={() => isLoggedIn.get()} fallback={<LoginForm />}>
<Dashboard />
</If>| Prop | Type | Reactive? | Description |
|---|---|---|---|
| when | () => boolean \| Signal<boolean> \| boolean | ✅ normalized like any other reactive prop | Same as condition above. |
| fallback | Node \| ComponentInstance | ❌ always literal | Same as elseChild above. |
| tagName | string | ❌ always literal | Same as options.tagName above. |
fallback/tagName are internally declared as raw (non-reactive) props — passing a literal Node or string works as-is, no wrapping needed.
Show(condition, child)
Toggles an element's display style between '' and 'none'. The element
stays in the DOM while visibility changes — no teardown and no lifecycle
hooks on show/hide toggles.
Use Show to preserve component state or skip remounting cost. Use If for full teardown semantics.
| Param | Type | Description |
|---|---|---|
| condition | () => boolean | Reactive condition accessor. |
| child | Node \| ComponentInstance \| string | The element to show or hide. |
const showPanel = createSignal(true);
Show(
() => showPanel.get(),
createElement('aside', { className: 'panel' }, ['Sidebar content']),
);Toggle (JSX wrapper around Show)
A JSX-only wrapper around Show for CSS-based conditional visibility. Unlike If, the child stays in the DOM — no teardown, no lifecycle hooks on toggle.
<Toggle when={() => showPanel.get()}>
<aside className="panel">Sidebar content</aside>
</Toggle>| Prop | Type | Reactive? | Description |
|---|---|---|---|
| when | () => boolean \| Signal<boolean> \| boolean | ✅ normalized like any other reactive prop | Same as condition in Show. |
children should be a single element (or an array whose first element is used).
For(itemsAccessor, keyFn, renderFn, options?)
Efficiently renders a keyed list. Each item gets its own reactive signal. When the array changes:
- Existing key →
itemSignal.set(newItem)— in-place reactive update, no remount. - New key → fresh signal + fresh DOM via
renderFn. - Removed key → full teardown + DOM removal.
- Duplicate key in same list update → logs an error and ignores later duplicates for that update cycle.
| Param | Type | Description |
|---|---|---|
| itemsAccessor | () => Item[] | Returns the current array. |
| keyFn | (item, index) => string \| number | Produces a stable key per item. |
| renderFn | (itemSignal, index) => Node | Builds the DOM for one item. Called once per new key — use itemSignal.get() inside reactive expressions to receive in-place updates without remounting. |
| options.equals | (prev: Item, next: Item) => boolean | Equality check used by each item's signal on existing-key updates. Defaults to deepEqual. |
| options.tagName | string | Optional. Tag name for the wrapper element. Defaults to 'span'. Use this when the wrapper's parent element only accepts specific direct children (e.g. { tagName: 'tbody' } inside a <table>). |
Returns the wrapper element (default <span>, configurable via options.tagName). Only the default/fallback <span> gets display:contents — an explicitly chosen tagName keeps its normal display, since it's assumed to already be a valid element for its context.
Known limitation: no
tagNamevalue makes the wrapper valid inside<ul>,<ol>, or<select>— those elements only accept their specific item tag (<li>/<option>) as direct children. AvoidFordirectly inside those elements until a future anchor-based (no-wrapper) implementation lands.
const todos = createSignal([
{ id: 1, text: 'Buy milk' },
{ id: 2, text: 'Walk the dog' },
]);
For(
() => todos.get(),
(item) => item.id,
(item) => createElement('li', {}, [() => item.get().text]),
);JSX usage — For also accepts a props object + a render-function child, so it can be used directly as a JSX element:
<For each={() => todos.get()} keyFn={(item) => item.id}>
{(itemSignal) => <li>{() => itemSignal.get().text}</li>}
</For>Why
keyFnand notkey?keyis a reserved JSX attribute — every JSX transform (Babel, esbuild, SWC, TypeScript) strips a literalkey={...}attribute out ofpropsbefore the component ever runs, using it instead for its own (unrelated) reconciliation bookkeeping. A component can never actually receive a prop namedkeyvia JSX syntax, soForuseskeyFnfor its item-keying function instead.
| Prop | Type | Reactive? | Description |
|---|---|---|---|
| each | () => Item[] \| Signal<Item[]> \| Item[] | ✅ normalized like any other reactive prop | Same as itemsAccessor above. |
| keyFn | (item, index) => string \| number | ❌ always literal, required | Same as keyFn above. |
| equals | (prev: Item, next: Item) => boolean | ❌ always literal | Same as options.equals above. |
| tagName | string | ❌ always literal | Same as options.tagName above. |
keyFn/equals/tagName are internally declared as raw (non-reactive) props — they're passed exactly as given, since wrapping a multi-argument callback like keyFn in computed() would break its contract (it would be invoked with zero arguments).
Custom equals for contenteditable / DOM-ahead-of-model UIs — the default
deepEqual skips notifying an item's subscribers when a replacement item is
structurally identical to the previous one. That's usually the right call for
static lists, but it breaks editors where the DOM can diverge from the model
(e.g. mid-edit) and a programmatic update "corrects" the model back to a value
that's deep-equal to what it already held — reactive bindings like
$dangerouslySetInnerHTML would never re-run, leaving stale DOM behind. Pass
a reference-identity equals and always assign a new object reference at
changed indices to force the update through:
For(
() => content.get(),
(item) => item.id,
(itemSignal) =>
createElement('p', {
contentEditable: true,
$dangerouslySetInnerHTML: () => itemSignal.get().text || '<br>',
}),
{ equals: (a, b) => a === b },
);Lifecycle Hooks
Lifecycle hooks must be called during component construction (i.e. synchronously inside a function component body or a class constructor). They register against the currently-active component context.
onMount(callback)
Runs after the component's DOM node is inserted into the document. If callback returns a function, that function is automatically registered as an unmount cleanup.
function MyComponent() {
onMount(() => {
console.log('mounted');
// optional cleanup — equivalent to calling onUnmount
return () => console.log('unmounted');
});
return createElement('div', {}, ['Hello']);
}onUnmount(callback)
Registers a cleanup callback that runs when the component is removed from the DOM. Alternative to returning a function from onMount.
function Timer() {
const tick = createSignal(0);
const id = setInterval(() => tick.set(tick.get() + 1), 1000);
onUnmount(() => clearInterval(id));
return createElement('span', {}, [() => `${tick.get()}s`]);
}onEffect(disposeFn)
Registers the return value of effect() as a component-scoped disposer. It is called automatically on unmount.
Use onEffect for signal effects. Use onUnmount for everything else (timers, event listeners, subscriptions).
import { effect } from '@esmj/signals';
function SyncTitle({ title }) {
// effect() returns a dispose function — pass it to onEffect
onEffect(effect(() => {
document.title = title.get();
}));
return createElement('span', {}, []);
}afterRender(callback)
Schedules callback after all pending renders and cascading reactive updates have fully settled (uses onFlush + a macrotask). Because all signal flushes are microtasks, they are guaranteed to drain before any macrotask runs.
Use this when you need to read layout or focus an element after the DOM has fully settled. Not a general post-render hook — for signal side-effects, use onEffect instead.
function FocusInput() {
let inputEl;
afterRender(() => {
inputEl?.focus();
});
return createElement('input', { $ref: (el) => (inputEl = el) }, []);
}Context API
Contexts let ancestor components provide values to any descendant without prop-drilling.
createContext(defaultValue)
Creates a context object. Pass defaultValue to use when no ancestor has called setContext.
Each createContext(...) call returns a unique token, so context values stay
isolated even when multiple contexts are active in the same subtree.
const ThemeContext = createContext('light');setContext(ctx, value)
Sets a context value inside the currently-constructing component. All descendants will inherit this value. Calling it outside component construction is a no-op.
function ThemeProvider() {
setContext(ThemeContext, 'dark');
return createElement('div', {}, [createElement(ChildComponent, {})]);
}getContext(ctx)
Reads the nearest context value during component construction.
function ThemedButton() {
const theme = getContext(ThemeContext); // 'dark' or default 'light'
return createElement('button', { className: `btn-${theme}` }, ['Click']);
}useRef(onAssign?)
Creates a signal-shaped ref object for use as $ref. Read the assigned element via ref.get() or ref.current, and access members directly on the ref itself (ref.focus(), ref.value) once an element has been assigned — property/method access is proxied through to the underlying element.
| Param | Type | Description |
|---|---|---|
| onAssign | (element: HTMLElement \| null) => void | Optional. Called whenever the ref element is assigned or cleared. |
import { createElement, useRef } from '@esmj/dom';
function FocusInput() {
const inputRef = useRef();
return createElement('div', {}, [
createElement('input', { $ref: inputRef }, []),
createElement(
'button',
{ onClick: () => inputRef.focus() }, // proxied to the input element
['Focus'],
),
]);
}Utilities
isSignalLike(value)
Returns true if value is a signal-like object (has a .get() method and is not a DOM Node). Works with both createSignal() and computed() values from @esmj/signals.
isSignalLike(createSignal(0)); // true
isSignalLike(computed(() => 1)); // true
isSignalLike(42); // falseAdvanced
The following exports are primarily useful when building abstractions or framework extensions on top of @esmj/dom. Most application code won't need them.
getContextFromElement(ctx, element)
Walks the DOM tree upward from element to find the nearest context value. Useful for accessing context outside of component construction (e.g. in event handlers that fire outside the component tree).
button.addEventListener('click', (e) => {
const theme = getContextFromElement(ThemeContext, e.target);
console.log('Theme at click target:', theme);
});normalizeProps(props)
Converts a plain props object so every value becomes signal-like (used internally by the component system):
- Already signal-like → passed through.
- Plain function → wrapped in
computed(). - Primitive value → wrapped in
createSignal(). children→ always passed through as-is.- Values wrapped with
keepLiteral()→ passed through raw (no wrapping).
const normalized = normalizeProps({ label: 'Click me', count: signal });
normalized.label.get(); // 'Click me'
normalized.count.get(); // reactive valuekeepLiteral(value)
Marks a prop value to be passed through normalizeProps as-is, without being wrapped in a signal or computed. Useful when you intentionally want to pass a raw value — including a function — to a component without any reactive wrapping.
function MyComponent({ onClick, label }) {
// onClick is the original function, label is the string
return createElement('button', { onClick }, label);
}
// Without keepLiteral: onClick would be wrapped in computed()
// With keepLiteral: onClick is passed through as the original function
createElement(MyComponent, {
onClick: keepLiteral(handleClick),
label: keepLiteral('Save'),
});getNodeComponent(element)
Returns the component instance associated with a DOM element, or null if none. Useful for inspecting the component tree from outside.
const instance = getNodeComponent(someElement);renderChild(parent, child)
Appends a single child value of any supported type to a DOM parent. This is the shared primitive createElement, If, and For all use to render children, exposed for authors building their own rendering helpers on top of @esmj/dom.
Supported child values: null/undefined/boolean (skipped), a DOM Node, a string/number (text node), a reactive () => value function, a signal-like value, a component instance, or an array of any of the above (rendered recursively).
| Param | Type | Description |
|---|---|---|
| parent | Element | The DOM node to append child to. |
| child | Node \| ComponentInstance \| string \| number \| Function \| Signal \| Array \| null \| boolean | The value to render. |
const parent = document.createElement('div');
renderChild(parent, 'plain text');
renderChild(parent, () => count.get()); // reactive text node
renderChild(parent, createElement('span', {}, ['child element']));withContext(ctx, fn)
Runs fn with ctx set as the active internal component context, restoring the previous context afterward (even if fn throws). Used internally to keep context-dependent lifecycle hooks and reactive callbacks (e.g. inside effect()) associated with the right component across re-renders; exposed for framework extension authors who need the same behavior.
| Param | Type | Description |
|---|---|---|
| ctx | * | The internal component context to activate for the duration of fn. |
| fn | () => * | The function to run with ctx active. |
import { withContext } from '@esmj/dom';
function useDeferredContext(ctx) {
// capture the context active during this call...
return (fn) => withContext(ctx, fn); // ...and reactivate it later, e.g. in an async callback or effect
}deepEqual(a, b)
Deep equality via JSON.stringify. Used internally by For to skip re-renders when an item's content hasn't changed.
deepEqual({ x: 1 }, { x: 1 }); // true
deepEqual({ x: 1 }, { x: 2 }); // falseExamples
Counter with conditional rendering
import { createSignal } from '@esmj/signals';
import { createElement, If, mount, onMount } from '@esmj/dom';
function EvenBadge() {
onMount(() => console.log('EvenBadge mounted'));
return createElement('span', { style: { color: 'green' } }, ['Even!']);
}
function Counter() {
const count = createSignal(0);
return createElement('div', {}, [
createElement(
'button',
{ onClick: () => count.set(count.get() + 1) },
[() => `Count: ${count.get()}`],
),
If(
() => count.get() % 2 === 0,
createElement(EvenBadge, {}),
),
]);
}
mount('#app', createElement(Counter, {}));Reactive list with For
import { createSignal } from '@esmj/signals';
import { createElement, For, mount } from '@esmj/dom';
function TodoApp() {
const items = createSignal([
{ id: 1, text: 'Buy milk' },
{ id: 2, text: 'Walk the dog' },
]);
function addItem() {
const text = prompt('New todo:');
if (text) {
items.set([...items.get(), { id: Date.now(), text }]);
}
}
function removeItem(id) {
items.set(items.get().filter((i) => i.id !== id));
}
return createElement('div', {}, [
createElement('button', { onClick: addItem }, ['Add todo']),
createElement('ul', {}, [
For(
() => items.get(),
(item) => item.id,
(item) =>
createElement('li', {}, [
() => item.get().text,
createElement(
'button',
{ onClick: () => removeItem(item.get().id) },
[' x'],
),
]),
),
]),
]);
}
mount('#app', createElement(TodoApp, {}));Class-based component
import { Component, createElement, mount } from '@esmj/dom';
class Card extends Component {
onMount() {
console.log('Card mounted:', this.props.title.get());
}
onUnmount() {
console.log('Card unmounted');
}
render() {
return createElement('div', { className: 'card' }, [
createElement('h2', {}, [this.props.title]),
...this.props.children,
]);
}
}
mount(
'#app',
createElement(Card, { title: 'Hello' }, [
createElement('p', {}, ['Card body text']),
]),
);Context API usage
import { createSignal } from '@esmj/signals';
import {
createElement,
mount,
createContext,
setContext,
getContext,
} from '@esmj/dom';
const UserContext = createContext(null);
function UserAvatar() {
const user = getContext(UserContext);
return createElement('img', { src: () => user.get().avatarUrl, alt: 'avatar' }, []);
}
function App() {
const user = createSignal({ name: 'Alice', avatarUrl: '/alice.png' });
setContext(UserContext, user);
return createElement('div', {}, [
createElement('h1', {}, [() => `Hello, ${user.get().name}`]),
createElement(UserAvatar, {}),
]);
}
mount('#app', createElement(App, {}));