@slimlib/jsx
v0.2.0
Published
Mini JSX renderer focused on web components with reactive primitives
Maintainers
Readme
@slimlib/jsx
Tiny JSX renderer (~6KiB minified, ~2.8 KiB gzip together with reactive primitives) with reactive primitives. Real DOM nodes, no virtual DOM.
import { signal } from '@slimlib/store';
import { render } from '@slimlib/jsx';
const Counter = () => {
const count = signal(0);
return (
<button on:click={() => count.set(count() + 1)}>
Count: {count}
</button>
);
};
render(() => <Counter />, document.body);Installation
npm install @slimlib/jsxConfigure tsconfig (or your bundler) to use the automatic JSX runtime:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@slimlib/jsx"
}
}Mental Model
Components run exactly once. A component is just a function that builds DOM and wires up reactive bindings. There is no re-render, no virtual DOM, no diff.
function Greeting(props) {
console.log('runs once at mount');
return <h1>Hello, {props.name}!</h1>;
}Updates flow exclusively through reactive primitives from @slimlib/store:
signal(value)— readable +.set()writable value.computed(fn)— derived value.effect(fn)— side-effect that re-runs on dependency change.
When you put a function in a JSX expression, the renderer wraps it in an effect() automatically. Signals from @slimlib/store are themselves callable functions, so you pass them in directly — no wrapper closure needed for a single-signal read:
const name = signal('World');
<div>
Hello, {name} {/* reactive text */}
<span class={() => active() ? 'on' : 'off'} /> {/* reactive attr — derived */}
</div>
name.set('there'); // text updatesSame model as SolidJS, but without the JSX-compiler magic. The wrapper form {() => sig()} is only needed when you combine multiple signals, do a ternary, or compute a derived value — anything beyond a single signal read. For per-property reactivity inside forEach, see Gotchas.
API
render(factory, container) => disposeFn
Mounts a JSX tree into a DOM container.
factorymust be a function that returns JSX. This is required so reactive bindings are created inside the render scope and torn down on dispose.- Returns a function that disposes all effects, event listeners, and refs in the tree, then removes the DOM range inserted by this render call.
const dispose = render(() => <App />, document.body);
// ...later
dispose();Commit timing
@slimlib/jsx wires reactive bindings (attribute effects, function-child effects, forEach reconciler) as eager effects internally — they run synchronously during render() so the first paint is fully populated before render() returns. There is no microtask gap between mount and first render; tests and SSR-style code can read the DOM immediately.
const dispose = render(() => <App />, document.body);
// document.body already contains the fully rendered tree.Subsequent re-runs (triggered by signal/state writes) still go through @slimlib/store's scheduler, which defaults to queueMicrotask. Multiple synchronous writes coalesce into one re-run as usual. To change that timing you have two options:
import { flushEffects, setScheduler } from '@slimlib/store';
// 1) Drive scheduling manually (drain after each write batch):
setScheduler(fn => fn()); // sync — every write commits inline
// or
setScheduler(myQueue); // your own — call flushEffects() when ready
// 2) Stay on microtask scheduling, but force-drain at known sync points:
write1();
write2();
flushEffects(); // commit bothInternal use of EffectOptions.EAGER
Internally, @slimlib/jsx calls effect(fn, EffectOptions.EAGER) for the bindings it sets up. This has three consequences worth knowing:
- First-run errors propagate to
render(). A function-child that throws, or aforEachbody that returns a non-Node, will throw synchronously fromrender()— you get a real stack trace at the call site and can wrap intry/catch. With the defaultDEFERREDmode those errors would be swallowed and logged by the scheduler's flush loop. activeScopeis the render scope during initial wiring. Function-child effects and per-itemforEachscopes are parented correctly without any internalactiveScopecapture.- No microtask gap. Calls that observe the DOM right after
render()(e.g.connectedCallback, integration tests, snapshot serializers) see the final tree without needingflushEffects()orawait Promise.resolve().
User-land effect() calls inside your components remain deferred by default — only the renderer's internal wiring is eager.
createElement(type, props, ...children)
The hyperscript factory the JSX runtime calls. You can use it directly:
import { createElement as h } from '@slimlib/jsx';
const node = h('div', { class: 'box' }, 'hello');type: string tag name OR a function component.props: object ornull.children: variadic.
Fragment
A no-op component that returns its children. Use with JSX <>...</>:
<>
<li>one</li>
<li>two</li>
</>Props, attributes, events, refs
Plain props
The renderer uses a prototype-setter heuristic: walks the element's prototype chain for a setter on the key. If found → property assignment (el.value = ...). Otherwise → setAttribute. Cached per (tagName, propName) pair.
<input value="hi" /> {/* property — types preserved */}
<div data-id="42" /> {/* attribute — no setter on Element.prototype */}
<x-custom config={obj} /> {/* property — custom element exposes setter */}Explicit prop: / attr: prefixes
Override the heuristic:
<input attr:value="initial" /> {/* force HTML attribute */}
<my-el prop:state={obj} /> {/* force JS property */}Events: on:event
<button on:click={() => console.log('clicked')}>OK</button>
<input on:input={e => name.set(e.currentTarget.value)} />Listeners are cleaned up automatically when the tree is disposed.
Refs
let ref;
<div ref={(el) => (ref = el)}>...</div>Called with the element on mount and with null on dispose.
Reactive values
A function in any prop becomes an effect:
<div
class={() => active() ? 'on' : 'off'}
style={() => `color: ${color()}`}
/>Children
- Primitives (string, number, boolean, null, undefined) —
null,undefined,false,trueare skipped. Others become text nodes. - Nodes — inserted directly.
- Arrays — recursively appended.
- Functions — wrapped in
effect()with comment-anchor boundaries; re-runs replace the sub-range.
<ul>
{items().map(item => <li>{item}</li>)} {/* static snapshot */}
{() => items().map(item => <li>{item}</li>)} {/* reactive (no keying!) */}
</ul>For long reactive lists with stable identity, use forEach instead — it keys nodes and reuses them on reorder.
Keyed lists: forEach
Long lists with stable identity should use the keyed list helper. It's shipped as a sub-entry so apps that don't need it pay nothing:
import { forEach } from '@slimlib/jsx/for-each';
import { signal } from '@slimlib/store';
const items = signal([{ id: 1, label: 'A' }, { id: 2, label: 'B' }]);
<ul>
{forEach(
items,
(item) => item.id,
(item, index) => <li>{() => item().label}</li>,
)}
</ul>each accepts a bare signal directly (it's a function). Inside body, item() returns the whole entry — so reading a single property reactively still needs the wrapper form {() => item().prop}.
Signature: forEach<T>(each: () => readonly T[], key: (item, index) => string | number, body: (item: () => T, index: () => number) => Node): DocumentFragment.
eachis a thunk read in the renderer's reactive scope; updates to the underlying signal trigger reconciliation.keymust be unique per row. Identical keys reuse the same DOM node and per-item reactive scope on reorder.bodyreceives reactive accessors foritemandindex— both update in place when the same key moves position or its value changes, without rebuilding DOM.- Each row gets its own sub-scope, so
on:listeners,refcallbacks, andeffect()calls inside a row are disposed when the row is removed (or when the parent tree is disposed). - Returns a
DocumentFragment— drop it anywhere JSX accepts a child (including inside a function-child return).
Bundle cost: 610 B gzip (sub-entry, separate from core).
SVG (and other namespaces)
JSX is evaluated bottom-up: children are constructed before their parent, so the renderer can't infer a namespace from the surrounding <svg> tag. Use the svg() factory to enter the SVG namespace for a sub-tree:
import { svg, html } from '@slimlib/jsx';
const Icon = () => svg(() => (
<svg viewBox="0 0 24 24" width="24" height="24">
<circle cx="12" cy="12" r="10" fill="currentColor" />
</svg>
));Every element created inside the svg() callback uses createElementNS('http://www.w3.org/2000/svg', …). Nesting works as expected — call html() to switch back inside <foreignObject>:
svg(() => (
<svg>
<foreignObject x="0" y="0" width="100" height="50">
{html(() => <div>HTML inside SVG</div>)}
</foreignObject>
</svg>
))The factory only affects elements created during the callback; once it returns, the previous namespace is restored. Generic signature: svg<T>(fn: () => T): T, same for html.
Gotchas
on:event={sig} and ref={sig} are NOT reactive. Both paths bail out before the reactive-function check: the renderer always treats on:* values as event listeners and ref values as callbacks. Pass a literal handler/ref function — not a signal:
{/* WRONG — sig becomes the click listener and fires with an Event arg */}
<button on:click={mySignal}>...</button>
{/* WRONG — sig is invoked once with the element, never re-invoked */}
<div ref={mySignal}>...</div>
{/* OK */}
<button on:click={() => mySignal.set(mySignal() + 1)}>...</button>forEach body: item() is the whole entry. Reading a single property is a derived value, so it needs the wrapper form:
{forEach(items, it => it.id, item => (
<li class={() => item().done ? 'done' : ''}>
{() => item().text}
</li>
))}Reuse the closure when binding multiple slots to the same signal. {sig} allocates nothing extra at the call site, but if you write {() => sig()} repeatedly each instance is its own closure. Hoist or just pass sig directly.
Design Notes
- One scope per
render()call, with sub-scopes per dynamic boundary. Components do NOT create their own scopes. Every function-child boundary ({() => ...}) and everyforEachrow gets a sub-scope that is disposed and replaced on re-run, soon:listeners,refcallbacks, and innereffect()calls don't leak when conditionals flip or list rows are removed. - Scheduler-agnostic commit.
@slimlib/jsxnever callsflushEffects()internally — that would silently force every pending@slimlib/storeeffect (including ones from other packages) to run on the renderer's terms. Instead, the renderer enqueues effects via the store's scheduler and trusts the host to decide commit timing. See Commit timing for the two supported modes. - DocumentFragment only when needed. When a component returns a single Node, the renderer inserts it directly. Fragment wrapping is reserved for primitives, arrays, and function-children — keeping deep-tree mounts cheap.
- Keyed reconciliation lives in a sub-entry.
forEachis opt-in via@slimlib/jsx/for-eachso apps that don't need keyed lists don't pay for the diff algorithm. A reverse-walk reorder usingnextSiblingchecks avoids the LIS step; reconcile is wrapped inuntracked()to prevent the outer effect from re-subscribing on item writes. - Prototype-setter cache. First touch of each
(tagName, propName)pair walks the prototype chain; result cached for the lifetime of the program. Same heuristic as vanjs.
