npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@slimlib/jsx

v0.2.0

Published

Mini JSX renderer focused on web components with reactive primitives

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);

Changelog

Installation

npm install @slimlib/jsx

Configure 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 updates

Same 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.

  • factory must 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 both
Internal 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 a forEach body that returns a non-Node, will throw synchronously from render() — you get a real stack trace at the call site and can wrap in try/catch. With the default DEFERRED mode those errors would be swallowed and logged by the scheduler's flush loop.
  • activeScope is the render scope during initial wiring. Function-child effects and per-item forEach scopes are parented correctly without any internal activeScope capture.
  • No microtask gap. Calls that observe the DOM right after render() (e.g. connectedCallback, integration tests, snapshot serializers) see the final tree without needing flushEffects() or await 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 or null.
  • 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, true are 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.

  • each is a thunk read in the renderer's reactive scope; updates to the underlying signal trigger reconciliation.
  • key must be unique per row. Identical keys reuse the same DOM node and per-item reactive scope on reorder.
  • body receives reactive accessors for item and index — 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, ref callbacks, and effect() 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 every forEach row gets a sub-scope that is disposed and replaced on re-run, so on: listeners, ref callbacks, and inner effect() calls don't leak when conditionals flip or list rows are removed.
  • Scheduler-agnostic commit. @slimlib/jsx never calls flushEffects() internally — that would silently force every pending @slimlib/store effect (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. forEach is opt-in via @slimlib/jsx/for-each so apps that don't need keyed lists don't pay for the diff algorithm. A reverse-walk reorder using nextSibling checks avoids the LIS step; reconcile is wrapped in untracked() 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.

License

MIT