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

Published

Mini JSX renderer focused on web components with reactive primitives

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

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

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 | undefined

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.
  • Context rides the scope tree. Provider stores values on the active scope keyed by the context symbol, chaining each scope's value table to its parent's via Object.create so descendants transparently see ancestor entries; inject (and RootProvider's probe) walk the same parent chain. No DOM traversal and no context-request events — resolution reuses the scope graph already maintained for effect disposal.

License

MIT