@slimlib/jsx
v0.5.0
Published
Mini JSX renderer focused on web components with reactive primitives
Maintainers
Readme
@slimlib/jsx
Tiny JSX renderer ~5.8KiB minified, ~2.4 KiB gzip together 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).
Querying children: queryChildren
Use the query helpers when a component needs a reactive view of descendants that match a CSS selector. They live in a
sub-entry, so the main JSX runtime does not include MutationObserver code unless imported.
import { queryChildrenRef } from "@slimlib/jsx/query-children";
const Tabs = () => {
const tabs = queryChildrenRef('[role="tab"]');
return (
<section>
<div ref={tabs.ref}>
<button role="tab">One</button>
<button role="tab">Two</button>
</div>
<p>{() => `${tabs().length} tabs`}</p>
</section>
);
};Signature:
queryChildren<T extends Element = Element>(
root: Element | DocumentFragment,
selector: string,
options?: MutationObserverInit
): Signal<readonly T[]>;
queryChildrenRef<T extends Element = Element>(
selector: string,
options?: MutationObserverInit
): Signal<readonly T[]> & {
ref: (root: Element | DocumentFragment | null) => void;
};Defaults are { childList: true, subtree: true, attributes: true }, which covers structural changes and selectors that
depend on attributes or classes. The returned signal updates only when the matched element list changes by identity.
queryChildren() reads the initial matches synchronously. queryChildrenRef() runs an initial query when the ref receives
an element, then relies on MutationObserver for children appended by JSX after the ref is called and for later DOM
changes. Passing null to the ref disconnects the observer and clears the signal; observers are also disconnected when
the owning render scope is disposed.
Property-only state changes are outside MutationObserver's reach. For example, setting input.checked = true may affect
:checked without producing an attribute mutation.
Context
Share values down the tree without prop-drilling. Context rides the scope tree (the same scopes described in Design Notes) — not the DOM — so resolution works for plain components, function-children, and forEach rows alike. It lives in the main entry and tree-shakes away when unused.
import { signal } from "@slimlib/store";
import { createContext, Provider, inject, render } from "@slimlib/jsx";
const ThemeCtx = createContext(); // createContext<T>() to type the provided value
const Toggle = () => {
const theme = inject(ThemeCtx); // read the provided signal once
return (
<button on:click={() => theme.set(theme() === "dark" ? "light" : "dark")}>
{theme} {/* reactive */}
</button>
);
};
const App = () => {
const theme = signal("dark");
return (
<Provider context={ThemeCtx} value={theme}>
{() => <Toggle />}
</Provider>
);
};
render(() => <App />, document.body);Because components run once, the idiomatic value is a stable reference — a signal, store, or controller. Consumers inject it once, then bind to it reactively.
createContext<T>() => Context<T>
Creates a unique, typed context key (a branded symbol). Pass the value type as the type argument; matching between providers and consumers is by key identity.
Provider
<Provider context={ctx} value={value}>
{() => children}
</Provider>Provides value for ctx to every descendant. children must be a function — the provider sets its value on the active scope and then renders children inside it, so the child has to be deferred (a DEV build throws if it isn't). The nearest provider wins, and siblings are isolated. A value={undefined} deliberately shadows an outer provider, so inject returns undefined for that subtree.
RootProvider
<RootProvider context={ctx} factory={() => value}>
{() => children}
</RootProvider>Like Provider, but provides only when no ancestor already provides ctx. On render it probes the scope chain: if an enclosing provider already supplies the context it stays transparent and descendants keep resolving to that one; otherwise it becomes the root. The factory is lazy and runs at most once — only when this element actually becomes the provider — so an expensive or side-effectful default is never built when an ancestor already exists. Presence is detected by key, so an ancestor that intentionally provides undefined is respected (this provider stays transparent).
// A widget that brings its own store, unless the host app already provides one.
const Widget = (props) => (
<RootProvider context={StoreCtx} factory={() => signal(0)}>
{() => props.children}
</RootProvider>
);The shape differs from Provider on purpose: Provider always provides, so an eager value is fine; RootProvider may skip providing, so it takes a lazy factory.
inject(context) => T | undefined
Reads the nearest provided value by walking up the scope chain. Returns undefined when no provider is found (or when a provider deliberately supplied undefined).
const theme = inject(ThemeCtx); // T | undefinedSVG (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. - Context rides the scope tree.
Providerstores values on the active scope keyed by the context symbol, chaining each scope's value table to its parent's viaObject.createso descendants transparently see ancestor entries;inject(andRootProvider's probe) walk the same parent chain. No DOM traversal and nocontext-requestevents — resolution reuses the scope graph already maintained for effect disposal.
