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

uele

v0.10.2

Published

uele reactive frontend library

Readme

UEle - Universal Element

A Reactive frontend library.

Version Badge size Badge size

yarn: yarn add uele

npm: npm i uele

cdn: https://unpkg.com/uele

module: https://unpkg.com/uele?module

Lite Version

Badge size Badge size

cdn: https://unpkg.com/uele/lite

module: https://unpkg.com/uele/lite?module

import { h, r, is, get, api, add, props, useLive } from "uele/lite";

// @preact/signals, ulive, usignal api config
api.effect = effect;
api.is = (v) => v.peek;
api.get = (v) => v.value;
  • Tiny Badge size
  • Simple API
  • Fast
  • JSX
  • Fragment
  • Components
  • SVG and MATH ML
  • Refs
  • Style Maps
  • Lazy Components
  • Promise
  • AsyncIterable
  • Observable (subscribe/Symbol.observable)
  • Control Flow Components - If, For, Show, Switch, Match
  • Extend with any reactive library using api - effect, is, get
  • Efficient Array Diffing with For and map
  • Add Any Diffing - Using api.diff
  • Automatic Cleanup for Subscriptions
  • Extend html attributes and props
  1. Counter — without and with build tools
  2. h
  3. lazy
  4. map
  5. Example
  6. Utility Functions — get, r, sub, is, f, useLive, add
  7. Attributes, properties and events
  8. Props
  9. Control Flow Components — If and Show, For, Switch and Match, Dynamic
  10. 30-second todos app
  11. UI primitives
  12. Presets: one-call interop
  13. Write once, run on any preset (uele/reactive)
  14. Flow components (uele/flow)
  15. Routing (uele/router)
  16. How it works
  17. Authoring guides — a plugin, a signal adapter, a diff engine
  18. Which one should I use?
  19. Best practices
  20. Cleanup Support
  21. Other Settings — any other reactive library, SVG and MathML
  22. Testing, types and size
  23. Errors
  24. Thanks and Inspiration
  25. License

Counter

Without Build tools

No bundler? One script tag is enough — unpkg serves the IIFE build, which publishes a single uele global (uele/lite publishes its own):

<script src="https://unpkg.com/uele"></script>
<script>
  const { h, useLive } = uele;
</script>

1. h

import { h, useLive } from "uele";

let [count, setCount] = useLive(0);

document.body.append(
  h("main", [
    h("button", { onclick: () => setCount((c) => c - 1) }, "-"),
    count,
    h("button", { onclick: () => setCount((c) => c + 1) }, "+"),
  ])
);

2. html

import { h, useLive } from "uele";
import htm from "htm";
const html = htm.bind(h);

let [count, setCount] = useLive(0);

document.body.append(
  html`
    <main>
      <button onClick=${() => setCount((c) => c - 1)}>-</button>
      ${count}
      <button onClick=${() => setCount((c) => c + 1)}>+</button>
    </main>
  `
);

3. f

import { h, useLive, f } from "uele";
const { button, main } = f(h);

let [count, setCount] = useLive(0);

document.body.append(
  main([
    button({ onclick: () => setCount((c) => c - 1) }, "-"),
    count,
    button({ onclick: () => setCount((c) => c + 1) }, "+"),
  ])
);

With Build tools

4. jsx

The package ships an automatic JSX runtime, so no h import is needed and components are resolved from the same module instance as import ... from "uele".

// tsconfig.json
{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "uele" } }
// or esbuild/vite - vite.config.js
export default {
  esbuild: { jsx: "automatic", jsxImportSource: "uele" }
};
import { useLive } from "uele";

let [count, setCount] = useLive(0);

document.body.append(
  <main>
    <button onClick={() => setCount((c) => c - 1)}>-</button>
    {count}
    <button onClick={() => setCount((c) => c + 1)}>+</button>
  </main>
);

uele/jsx-runtime and uele/jsx-dev-runtime only re-export the shared h (Fragment === h) - importing both the runtime and the library never duplicates the implementation.

h

let frag = (
  <>
    {asyncIterable} or {promise} {/* or any html node, component, signal or library */}
  </>
);

<></> is h: the fragment is the h function itself (Fragment === h), so a fragment simply returns its children — there is no wrapper element and nothing to unwrap.

lazy

Load a component lazily with an optional fallback

const LazyComp = lazy(() => import("./SomeComp"), <div>Failed</div>);

map

Keyed list rendering with node reuse.

import { map } from "uele";

let items = o([1, 2, 3]);

const Items = () => {
  return map(
    items,
    (item, i) => (
      <div>
        {item} {i}
      </div>
    ),
    <div>No items</div>,
    (item, i) => item.id // optional key, defaults to the item itself
  );
};

Node reuse: a node is reused while its key is present, and the callback is not re-run for reused keys. Items are keyed by identity (key = (v, i) => v) unless a key function is given, so a pure reorder only moves existing nodes - the rendered content (including i) stays as it was for that node. Give a key (or make the content reactive) when the callback output depends on the index. For uses the same engine:

<For each={items} key={(item) => item.id} fallback={<div>No items</div>}>
  {(item, i) => <div>{item} {i}</div>}
</For>

Lists accept any iterable (Array, Set, generator, NodeList). Nodes whose keys disappear are released immediately, so filtering a large list does not leak nodes. A node is reused at most once per render, so duplicate keys are safe: with the default key [1, 1, 2] renders three elements (the second 1 builds a fresh node).

Example

import {
  h,
  lazy,
  api,
  If,
  For,
  map,
  get,
  r,
  useLive,
  props,
} from "uele";

import { o, effect, memo } from "ulive/fn"; // Or any other reactive library

// ulive settings
api.effect = effect;
api.is = (v) => v?.peek;
api.get = (v) => v();

// Check below for other reactive library settings

const LazyComp = lazy(() => import("./lazy"));

// Async Component
const TodoItem = async ({ id }) => {
  let api = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
  let todo = await api.json();

  return (
    <li>
      <span>{todo.title}</span>
      <input type="checkbox" checked={todo.completed} />
    </li>
  );
};

const count = o(0);
const inc = () => count(count() + 1);
const dec = () => count(count() - 1);

const Counter = () => {
  let square = memo(() => count() * count());
  let cube = memo(() => square() * count());
  effect(() => console.log(count(), square(), cube()));
  return (
    <div>
      <div>
        Count: {count} {square} {cube}
      </div>
      <button onclick={inc}>+</button>
      <button onclick={dec}>-</button>
    </div>
  );
};

// AsyncIterator

const asyncIterable = {
  [Symbol.asyncIterator]() {
    return {
      i: 0,
      next() {
        if (this.i < 200)
          return new Promise((ok) =>
            setTimeout(
              () =>
                ok({
                  value: <div>{this.i++}</div>,
                  done: false,
                }),
              10
            )
          );
        return new Promise((ok) => ok({ done: true }));
      },
    };
  },
};

let items = o([1, 2, 3]);

const App = () => (
  <main>
    <Counter />
    <LazyComp />
    <TodoItem />
    {asyncIterable}
    {map(items, (item, i) => (
      <div>
        {item} {i}
      </div>
    ))}
    <svg>...</svg>
  </main>
);

document.body.append(<App />);

Utility Functions

get

A utility function to handle and unwrap values of signals, observables, etc., especially functions.

import { get } from "uele";

let value = get(someReactiveSignal);

r

The r function handles reactive subscriptions, providing a callback when the value changes.

import { r } from "uele";

r(someObservable, (value, is) => {
  console.log("Value changed to", value, is);
});

sub

sub is the subscription itself — no unwrapping, no "run once for plain values": it returns the unsubscribe function, so you decide when it ends. Errors are the third argument.

import { sub } from "uele";

const off = sub(source, (value) => console.log(value), (err) => console.error(err));

// later - the subscription is released, and the callback with it
off();

is

is(v) answers the question uele asks about every child: "is this reactive?" - true for cells of the configured signal library, functions, promises, observables (subscribe or Symbol.observable) and async iterables.

import { is } from "uele";

const node = document.createElement("div");

is(cell);    // true  -> uele subscribes and re-renders it
is(node);    // false -> appended as is
is(42);      // false
is(() => 1); // true  -> a function child is an effect (see "How it works")

f

The f function is a utility that provides a convenient shorthand for creating elements using the h function. It returns a proxy that allows you to call HTML elements as functions directly. The property name is used as the tag name, so f(h)["my-widget"]({ id: "x" }) creates a custom element.

import { h, useLive, f } from "uele";
const { button, main } = f(h);

const [count, setCount] = useLive(0);

document.body.append(
  main([
    button({ onclick: () => setCount((c) => c - 1) }, "-"),
    count,
    button({ onclick: () => setCount((c) => c + 1) }, "+"),
  ])
);

useLive

useLive helps manage dynamic parts of the DOM by tracking specific start and end markers.

import { useLive } from "uele";

let [live, setLive] = useLive("....");

let c = 0;
setInterval(() => {
  setLive(c++);
}, 1000);

const App = () => <main>{live}</main>;

add

add(...children) is the step h runs on its children: it flattens arrays, turns plain values into text nodes and turns anything reactive (a cell, a thunk, a promise, an observable, an async iterable) into a live region, and returns a plain Node[]. Reach for it when you mount content yourself.

import { add } from "uele";

// everything below is optional: arrays, null, and pending promises are all fine
const nodes = add("Hello", [1, 2, 3].map((n) => <li>{n}</li>), fetchCount());
document.body.append(...nodes);

Attributes, properties and events

  • null/undefined/false remove an attribute, true sets it to "", numbers are stringified.
  • Attributes that exist as element properties (value, checked, id, textContent, ...) are set as properties and accept reactive values; null/undefined empty them instead of writing "null".
  • className maps to class. A class object toggles classes ({ active: signal }), a string/reactive string sets the attribute, and an array is joined by the DOM (["a","b"] -> "a,b").
  • style accepts a string or an object; object keys may be camelCase, kebab-case or custom properties ("--my-var"), values may be reactive and null/undefined clears them. Numbers must carry a unit.
  • on* handlers are assigned as properties when the element has that handler (onclick), otherwise they are bound with addEventListener, so custom events work: h("div", { onmyevt: fn }). Assigning a falsy value unregisters a native handler.
  • ref receives (element, props) for elements.
  • Attributes are applied after the children are built.

Props

The props object allows setting custom properties and event handlers on elements.

import { props } from "uele";

props.set("autofocus", (tag, value, name, attrs) => {
	setTimeout(() => tag.focus(), 0);
});

props.set("validate", (node, validateFn) => {
	node.addEventListener("input", () => {
		const error = validateFn(node.value);
		if (error) {
			console.log("error", error);
		} else {
			console.log("no err");
		}
	});
});

props.set("tooltip", (node, value) => {
	const tooltip = <div class="tooltip">{value}</div>;
	node.addEventListener("mouseenter", () => {
		document.body.appendChild(tooltip);
		const { left, top } = node.getBoundingClientRect();
		tooltip.style.position = "absolute";
		tooltip.style.left = `${left}px`;
		tooltip.style.top = `${top - 20}px`;
	});

	node.addEventListener("mouseleave", () => {
		tooltip.remove();
	});
});

props.set("unmount", (node, cleanupFn) => {
	// Check if the node has a parent node
const setupObserver = () => {
	if (node.parentNode) {
		// Create a MutationObserver to watch for DOM changes (removals)
		const observer = new MutationObserver((mutationsList) => {
			for (let mutation of mutationsList) {
				mutation.removedNodes.forEach((removedNode) => {
					if (removedNode === node) {
						// Node has been removed, trigger the cleanup function
						cleanupFn();
						observer.disconnect(); // Stop observing
					}
				});
			}
		});

		// Start observing the parent node for childList changes (node removals)
		observer.observe(node.parentNode, { childList: true });
	} else {
		// If node has no parentNode, retry after DOM insertion
		requestAnimationFrame(setupObserver); // Try again on the next frame
	}
};

	setupObserver(); // Initialize the observer
});
<>
	<input autofocus/>
	<input validate={(val) => (val.length < 3 ? "Too short" : "")} />
	<button tooltip={<div>click me!</div>}>Hover me</button>
	<div ref={(v) => { setTimeout(() => v.remove(), 2000)}}
		 unmount={() => console.log("unmounted!")}>
		Remove me
	</div>
</>

Control Flow Components

Control flow components accept boolean or reactive values for conditions.

If and Show

import { If, Show } from 'uele';

<>
  <If when={cond} fallback={<div>False</div>}>
    <div>True</div>
  </If>

  <Show when={cond} fallback={<div>False</div>}>
    <div>True</div>
  </Show>
</>

For

import { For } from "uele";

<For each={[1, 2, 3]} fallback={<div>No Items</div>}>
  {(val) => <div>{val}</div>}
</For>;

Switch and Match

import { Switch, Match } from "uele";

<Switch fallback={<div>Default case</div>}>
  <Match when={condition1}>
    <div>Case 1</div>
  </Match>
  <Match when={condition2}>
    <div>Case 2</div>
  </Match>
</Switch>;

Dynamic

Dynamic renders whatever component it is handed, with the rest of the props — a component chosen at runtime.

import { Dynamic } from "uele";

<Dynamic component={user.isAdmin ? AdminPanel : GuestPanel} user={user} />;

component is read when the element is created, so pick it inside a function component (or a thunk) when the choice itself depends on a signal — that parent re-runs, and Dynamic is rebuilt with the new component.

30-second todos app

Copy, paste, run — the whole app in JSX (this is the exact code test/readme.test.jsx runs):

import { For } from "uele";
import { usePreactSignals } from "uele/adapters/preact";
import { signal } from "@preact/signals-core";
import { Form, Row, Card, Stack, Button, Input, Checkbox, Label } from "uele/ui";

usePreactSignals(); // one call, whole api configured

const TodoApp = () => {
  const text = signal("");
  const todos = signal([]);
  let seq = 0;
  const add = () => {
    const title = text.value.trim();
    if (title) todos.value = [...todos.value, { id: ++seq, title, done: false }];
    text.value = "";
  };
  const toggle = (todo) =>
    (todos.value = todos.value.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t)));

  return (
    <Card>
      <h1>Todos</h1>
      <Form onSubmit={add}>
        <Row>
          <Input model={text} placeholder="What needs doing?" />
          <Button type="submit">Add</Button>
        </Row>
      </Form>
      <Stack>
        <For each={todos} fallback={<Label>Nothing here</Label>}>
          {(todo) => (
            <Row>
              <Checkbox checked={todo.done} onChange={() => toggle(todo)} />
              <Label style={todo.done ? { textDecoration: "line-through" } : null}>{todo.title}</Label>
              <Button onclick={() => (todos.value = todos.value.filter((t) => t.id !== todo.id))}>×</Button>
            </Row>
          )}
        </For>
      </Stack>
    </Card>
  );
};

document.body.append(TodoApp());

The same app, written for each of the eleven signal libraries, lives in examples/todos/ — each one is a complete runnable file (mountTodos(document.body)) and is driven end to end by test/examples.test.js: add, toggle, delete, filter (all/active/completed), clear-completed and independent instances.

UI primitives

uele/ui is a dozen thin wrappers over h() so the common tags read like components. Every prop is live — pass a signal anywhere a value is expected:

import { Form, Field, Button, Input, Textarea, Checkbox, Select, Card, Stack, Row, Label } from "uele/ui";

<>
  <Button onclick={save}>Save</Button>                  {/* defaults to type="button" */}
  <Input model={cell} />                                {/* two-way bound (a value cell works too) */}
  <Checkbox model={done} />                             {/* checked <-> boolean */}
  <Select model={choice} options={["a", { value: "b", label: "B" }]} />
  <Field label="Name"><Input model={name} /></Field>
  <Card>…</Card> <Stack>…</Stack> <Row>…</Row>           {/* minimal layout defaults */}
  <Form onSubmit={save}>…</Form>                        {/* preventDefault, then onSubmit */}
</>

They are plain functions, so the call form (Card({ children: "…" })) works too — JSX is just nicer.

model accepts any writable cell our presets produce (.value, .set(), .next(), .setState()) or a plain setter function. Pass the cell as value/checked instead and it becomes the model automatically.

Presets: one-call interop

Instead of wiring the api object by hand, import a preset and call it:

import { usePreactSignals } from "uele/adapters/preact";

usePreactSignals(); // that is the whole setup

There is one preset per supported signal library (uele/adapters/* - 11 of them, ulive to solid-js) and per diff engine (uele/diffs/* - 8, the built-in marker walk through swapdom, morphdom and fast-array-diff). Every preset assigns the complete surface - unused hooks are cleared, so switching presets can never leave stale configuration behind - accepts an optional target (usePreactSignals(liteApi) configures uele/lite), and treats the signal/diff library as an optional peer dependency: install the one you use, nothing else is pulled in. All of them are documented with runnable examples in docs/PRESETS.md and driven end to end by test/presets.test.js.

Write once, run on any preset (uele/reactive)

Utilities and primitives should not care which signal library is installed. uele/reactive is nine functions that delegate to the hooks the active preset published, so this counter runs unchanged on ulive, preact, vue, usignal, alien-signals, s-js, mobx and solid — and, for the parts that need no effect runner, on rxjs, nanostores and @tanstack/store:

import { signal, computed, read, write, effect, batch, untracked, canEffect, canCompute } from "uele/reactive";

// written once, no knowledge of the library underneath
const useCounter = (start = 0) => {
  const count = signal(start);
  const doubled = canCompute() ? computed(() => read(count) * 2) : null;
  return { count, doubled, inc: () => write(count, read(count) + 1) };
};

// and it renders like any other cell
const { count, inc } = useCounter();
document.body.append(<button onclick={inc}>{count}</button>);

signal, computed, effect, batch and untracked are the authoring hooks; read/write/isCell talk to any cell shape; canEffect()/canCompute() are the two feature detections you need (a subscribe-only store has no effect runner — ask instead of guessing). uele/ui, uele/flow and uele/router are all built on this layer, which is why they work with every preset.

Flow components (uele/flow)

import { Suspense, Await, ErrorBoundary, Portal, Title } from "uele/flow";

<>
  <Title>My app</Title>{/* document.title: a string, a cell or a thunk */}
  <ErrorBoundary fallback={(e) => <p>Failed: {e.message}</p>}>{risky()}</ErrorBoundary>
  <Suspense fallback={<Spinner />}>{lazyView()}</Suspense>
  <Await for={fetchUser(id)} fallback={<Spinner />}>{(user) => <b>{user.name}</b>}</Await>
  <Portal mount={document.body}><Toast /></Portal>
</>

Await takes a promise, a thunk or a cell (a cell re-resolves whenever it changes, and a late response never overwrites a newer one). Suspense shows its fallback until the async children resolve. ErrorBoundary catches a synchronous throw and a rejected promise, keeps rendering the fallback and calls onError. Portal moves its children into another container and leaves a comment marker where it was. Every one of them is signal-agnostic: the reactive parts are function children, so uele re-runs and disposes them — and they work on all eleven presets, not just the ones with an effect runner.

Routing (uele/router)

useRouter talks to any router that answers fetch(Request) with the view — uflite/nano, createRouter() (built in, zero dependencies) or your own:

import { createRouter, useRouter, Link, Router } from "uele/router";
import { usePreactSignals } from "uele/adapters/preact";

usePreactSignals();

const app = createRouter();
app.get("/", () => <h1>Home</h1>);
app.get("/user/:id", ({ params }) => <h1>User {params.id}</h1>);
app.get("*", () => <h1>404</h1>);

// one line: the live view, click interception, and a navigate() function
const App = () => {
  const [view, linkProps, navigate] = useRouter(app);
  return (
    <main {...linkProps()}>
      <nav>
        <Link to="/">Home</Link>
        <Link to="/user/42">A user</Link>
      </nav>
      {view}
      <button onclick={() => navigate("/user/7")}>Go</button>
    </main>
  );
};

document.body.append(<Router app={app} />);

Routes support :params, * wildcards and query strings (request.query); linkProps() intercepts in-app <a href="/…"> clicks (external links, target="_blank" and modifier clicks fall through to the browser) and navigate(path) pushes a history entry. Because the view is just a cell, the router works with every signal preset — and with uflite/nano, where app.fetch(request) resolves to the raw handler result.

How it works

Three ideas, and everything else follows from them.

  • A dynamic child is a marked region. A cell, a thunk, a promise, an observable or an async iterable becomes a pair of marker nodes around its current content. Updating it replaces what sits between the markers — there is no vdom and no component tree to reconcile. useLive gives you that region by hand.
  • A function child is an effect. add sees a function and runs it inside api.effect, so it re-runs whenever the cells it read change, and its previous content is replaced and released at that moment. That is why {count} and {() => count()} both stay live, and why a component is just a function called with (props, ...children). With the default api.effect a thunk runs once — install a preset (or set api.effect) and it re-runs for as long as the region is in the document.
  • Lists are keyed, and a key is an identity. map/For keep a Map of key → node: a node survives while its key is present, and the region's engine (api.diff) only inserts, moves and removes real nodes.

Nothing is scheduled: an update is synchronous with the write that caused it (a library that batches its own writes — usignal does — paints on the next tick). Use your preset's batch() to coalesce a burst of writes.

Authoring guides

uele has no plugin registry and no build step: the extension points are plain values you assign — the api object (reactivity and diffing), the props Map (custom attributes) and h itself. So a plugin, a signal adapter and a diff engine are all the same thing: a function that installs something, and optionally returns its own undo. The eleven signal presets and eight diff presets that ship are written exactly that way — copy adapters/preact.js or diffs/swapdom.js as a starting point, and read docs/ADAPTERS.md for the pitfalls we hit writing them.

Write a plugin

import { r, props } from "uele";

// a plugin is a function: install, then return the undo
export const tooltip = () => {
  // custom props get the RAW value - wrap it in r() and any cell works
  props.set("tooltip", (node, value) => r(value, (text) => (node.title = text == null ? "" : text)));
  return () => props.delete("tooltip");
};

// plugins compose: a kit is a plugin that installs plugins
export const useKit = () => {
  const undo = [tooltip(), autofocus()];
  return () => undo.forEach((off) => off());
};
// usage — `label` may be a cell, a thunk or a plain string
<button tooltip={label}>Hover me</button>

Rules of thumb: a props handler runs for elements only (a component gets its props untouched), it is called as (node, value, name, attrs) while the element is being built, and it must not assume the node is in the document yet — ref (or the unmount prop in Cleanup Support) is the hook for that. Use a name that cannot collide with a future HTML attribute.

Write a signal adapter

import { api } from "uele";
import { scoped, wire } from "uele/adapters/util"; // the helpers every shipped preset uses

export const useMySignals = (target = api) => {
  const s = scoped(myEffect); // wraps the library's effect runner for uele
  return wire(target, {
    // the four hooks uele itself needs
    is: (v) => v instanceof Cell, //   "is this one of our cells?"
    get: (v) => v.peek(), //           read it (and subscribe, from inside an effect)
    effect: s.effect, //               run fn now, re-run when the cells it read change
    cleanup: s.cleanup, //             register a teardown for the current run
    // the authoring hooks - uele/reactive builds on these, so uele/ui, uele/flow and
    // uele/router keep working on your preset too
    signal: (v) => new Cell(v),
    derived: (fn) => myComputed(fn),
    set: (cell, v) => cell.set(v),
    batch: (fn) => myBatch(fn),
    untracked: (fn) => myUntracked(fn)
  });
};
import { api as liteApi } from "uele/lite";

useMySignals(); // configure the main instance (the default target)
useMySignals(liteApi); // or the lite one

Call it once, before you render: api is one shared object. wire assigns every hook, clearing the ones your library has no answer for, so switching presets can never leave a stale hook behind. Only is, get, effect and cleanup are required — skip signal/derived/set/batch/untracked for a subscribe-only library and uele/reactive will report the missing features through canEffect()/canCompute() instead of failing.

Write a diff engine

import { api } from "uele";

export const useMyDiff = (target = api) => {
  target.udom = undefined; // unset = the swapdom signature below
  target.diff = (parent, oldNodes, newNodes, end) => {
    const keep = new Set(newNodes);
    oldNodes.forEach((node) => {
      if (!keep.has(node) && node.parentNode === parent) parent.removeChild(node);
    });
    let anchor = end || null; // walk backwards: each node before the one placed last
    for (let i = newNodes.length - 1; i >= 0; i--) {
      const node = newNodes[i];
      if (node.parentNode !== parent || node.nextSibling !== anchor) parent.insertBefore(node, anchor);
      anchor = node;
    }
  };
  return target;
};

The contract, in four lines:

  1. oldNodes are the nodes currently in the region, and they are children of parent.
  2. When you return, parent holds exactly newNodes, once each, in order, before the end marker.
  3. Remove every old node you do not reuse; leave every other child of parent (including the markers) alone.
  4. Mutate newNodes in place when you keep an old node instead of a new one — uele keeps that array as the region's current content. The return value is ignored.

end is the region's end marker — always a node, and always a child of parent — so insertBefore(node, end) is how you append to the region. Engines that morph elements in place (morphdom, nanomorph) need rule 4 the most; diffs/util.js ships morphAdapter(fn) for exactly that. Prefer the older udomdiff signature? Set api.udom to the key/get callback and uele calls api.diff(parent, oldNodes, newNodes, api.udom, end) — that is all diffs/udomdiff.js does.

Test it

The conformance suites are parameterised lists, so a new preset is one entry away from the whole matrix: add your adapter to ADAPTERS in test/helpers/signal-adapters.js — the signal conformance suite then runs it against both entry points — or your engine to ENGINES in test/helpers/diff-engines.js, which also feeds the 42 250 list-state transitions in test/diff.transitions.test.js. The three snippets above are run as printed by test/authoring.test.jsx, and docs/ADAPTERS.md ends with the checklist every shipped preset has to pass.

Which one should I use?

Signal presets (uele/adapters/*)

Install the library you want, call one function. Every preset is driven by the same conformance suite. "install size" is the npm unpacked size of the library (all published files), not its bundle cost.

| preset | library | install | preset | read | write | reactive thunks | subscribe path | notes | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | uele/adapters/ulive | ulive | 15 kB | 266 B | cell.value | cell.value = v | yes | no | smallest default; the baseline uele is tested against | | uele/adapters/preact | @preact/signals-core | 254 kB | 360 B | cell.value | cell.value = v | yes | yes | signals also expose .subscribe(); great with Preact | | uele/adapters/vue | @vue/reactivity | 342 kB | 323 B | cell.value | cell.value = v | yes | no | ref()/computed(); the effect needs stop(runner) | | uele/adapters/usignal | usignal | 46 kB | 333 B | cell.value | cell.value = v | yes | no | tiny; attributes a read to every enclosing effect; usignal batches, so the first paint lands on the next tick | | uele/adapters/alien-signals | alien-signals | 46 kB | 288 B | cell() | cell(v) | yes | no | plain-function signals, very fast | | uele/adapters/sjs | s-js | 221 kB | 379 B | cell() | cell(v) | yes | no | classic S.js; S.root owns the disposer | | uele/adapters/mobx | mobx | 4723 kB | 365 B | cell.get() | cell.set(v) | yes | no | boxed observables; configure mobx yourself | | uele/adapters/rxjs | rxjs | 4392 kB | 431 B | cell.value | cell.next(v) | no | yes | subscribe-only: no reactive thunks; BehaviorSubject reads sync | | uele/adapters/nanostores | nanostores | 54 kB | 348 B | cell.get() | cell.set(v) | no | yes | subscribe-only atoms; tiny and framework-neutral | | uele/adapters/tanstack | @tanstack/store | 121 kB | 437 B | cell.state | cell.setState(v) | no | yes | Store.subscribe() is not synchronous, so a bare Store paints on its first change - new Cell(v) (or api.signal(v)) from the preset paints immediately | | uele/adapters/solid | solid-js | 1051 kB | 471 B | cell() | cell.write(v) | yes | no | client build (solid-js/dist/solid.js) |

  • reactive thunks — functions and components that re-run when their signals change (needs an effect runner).
  • subscribe path — driven through .subscribe(): works without an effect runner, but reactive thunks and components cannot re-render themselves.

Short version: ulive if you have no preference (smallest, zero deps), preact signals if you use Preact, nanostores if you only need subscribe-based stores, solid if you are already in solid-js.

Diff engine presets (uele/diffs/*)

Every engine satisfies the same api.diff contract — verified over 42 250 list-state transitions. The built-in marker walk is the right answer for most apps; swap one in when you have a specific reason.

| preset | dependency | install | preset | strategy | pick it when | | --- | --- | --- | --- | --- | --- | | uele/diffs/builtin | — | — | 208 B | marker walk built into uele | default; zero dependencies | | uele/diffs/swapdom | swapdom | 45 kB | 209 B | keyed list diff written for uele's signature | small keyed lists | | uele/diffs/udomdiff | udomdiff | 25 kB | 245 B | keyed diff, the smallest of the classic trio | battle-tested keyed lists | | uele/diffs/domdiff | domdiff | 48 kB | 279 B | keyed diff with an options object ({ before }) | keyed lists, WebReflection stack | | uele/diffs/morphdom | morphdom | 199 kB | 291 B | morphs elements in place (the old element survives) | server-rendered HTML, keep focus/media state | | uele/diffs/nanomorph | nanomorph | 85 kB | 271 B | morphs in place, tiny | choo-style apps, keep DOM state | | uele/diffs/fast-array-diff | fast-array-diff | 40 kB | 448 B | an LCS patch decides the order, uele moves the nodes | ordered lists with few moves | | uele/diffs/rebuild | — | — | 303 B | remove all, insert all | tiny regions, or as a baseline in tests |

Short version: keep the built-in walk; reach for swapdom / udomdiff / domdiff for long keyed lists, morphdom / nanomorph when you must preserve DOM state (focus, media, CSS transitions).

Best practices

  • Install one signal preset (and at most one diff preset), once, at startup. api is a single shared object, so a second preset of the same kind simply overwrites the first - wire clears every hook it does not set. Install it before you render: a thunk that was rendered with the default api.effect runs once and never updates.

  • Replace, do not mutate. A cell only notifies on a write:

    todos.value = [...todos.value, todo]; // one write, one paint
    todos.value.push(todo); // silent: nothing repaints
  • Return the undo. r() and sub() hand back an unsubscribe function. Keep it and call it when the subtree goes away - see Cleanup Support.

  • Key a list when the row is not the item. The default key is the item's identity, which is perfect for primitives and wrong for objects you replace: pass key={(item) => item.id} (and give the content its own signals if the row depends on the index).

  • Render, do not act. A function child re-runs on every change of what it reads, so anything with a side effect - appending to the document, starting a timer, subscribing - repeats on every update. Do that in an event handler, in ref, or in an effect you own and dispose.

  • Write utilities against uele/reactive. signal/computed/read/write/effect work on every preset, and canEffect()/canCompute() tell you when a library cannot do something (the subscribe-only ones have no effect runner) - ask, do not assume. That is how uele/ui, uele/flow and uele/router stay preset-agnostic.

  • Handle errors where you subscribe. A rejected promise or a failed observable never throws into the render: the previous content stays. r(source, next, error) is the form that sees the error.

  • Keep the built-in diff engine unless you have a concrete reason (long keyed lists, or DOM state you must preserve). It has no dependencies, and it is the engine the conformance suite is written around.

  • Unwrap values in a custom prop. A props handler is handed the raw value; r(value, (v) => …) is what makes a cell, a thunk or a plain value all work.

  • Use uele/lite for a widget, the full build for an app. lite is ~2.4 kB, has its own api instance (so it can run a different preset than the page) and no control flow components.

  • Detach deliberately. uele replaces a region's content on every update, and usub releases a subscription once its source is unreachable - but a manual subscription to a long-lived store keeps running until you call the unsubscribe. Cleanup Support walks through the options.

Cleanup Support

sub/r return an unsubscribe function and usub registers subscriptions with a FinalizationRegistry, so a subscription is released once the source (signal/observable) is garbage collected.

Since usub 0.9.0 (which uele depends on), disposing a subscription releases the source and the callback immediately - the effect runner's disposer is no longer parked in the registry - while the finalizer safety net remains for subscribe/promise/async-iterator sources. test/memory.test.js asserts the exact release under --expose-gc: zero retained chains, disposed or not, and it fails if any chain survives.

(History: usub 0.8.x parked every disposer in the registry, and a parked value reaches its source - so the entry could never clear and each subscription retained one chain (source + last rendered nodes) forever, even after stop(). Upgrading to usub 0.9.0 is the fix; test/memory.test.js pins both halves of that rule.)

For long lived stores, disposing when the subtree unmounts is still good hygiene - it stops the updates the moment the subtree goes away:

import { h, props, api } from "uele";

props.set("unmount", (node, cleanup) => {
  const observe = () => {
    if (!node.parentNode) return requestAnimationFrame(observe);
    const observer = new MutationObserver((records) => {
      for (const record of records)
        for (const removed of record.removedNodes)
          if (removed === node) (cleanup(), observer.disconnect());
    });
    observer.observe(node.parentNode, { childList: true });
  };
  observe();
});

// per binding: keep the unsubscribe returned by r()/sub()
const off = r(store, render);
h("div", { unmount: off }, render);

Alternatively subscribe by hand (r(store, render) + a disconnect on route change) and let add() render plain functions - a function child is re-evaluated on every change of the signals it reads.

Other Settings

UEle can be configured to work with any reactive library by setting the api object accordingly.

Refer more at usub

// preact/signals-core or usignal settings
api.effect = effect;
api.is = (v) => v instanceof Signal; // or preact signals
api.get = (v) => v?.value;

// oby or sinuous settings
api.effect = effect; // or api.effect = subscribe
api.is = (v) => isObservable(v); // or api.is = (v) => v?.$o;
api.get = (v) => v?.();

// solid-js settings
api.effect = createEffect;
api.is = (v) => v?.name?.includes("readSignal");
api.get = (v) => v?.();

For any other reactive library

Set the api object to match the library's functions:

import { api } from 'uele';

api.effect = ...; // Function to create an effect
api.is = (v) => ...; // Function to check if a value is reactive
api.get = (v) => ...; // Function to get the current value
api.cleanup = ...; // Explicit cleanup function for solid.js, sinuous and similar
api.any = ...; // Go crazy with anything.
api.diff = ...; // Any diffing library - signature below
api.udom = ...; // udomdiff `get` helper, switches api.diff to the udomdiff signature

api.diff receives (parent, oldNodes, newNodes, end) and must place newNodes before the end marker (this is the swapdom signature). Set api.udom to use the udomdiff signature instead: api.diff(parent, oldNodes, newNodes, api.udom, end).

SVG and MathML

Tag names are matched against a tag list (isSVG) to pick the namespace, because the parent element is not known while children are being created. Custom elements must contain a dash (as in the spec), which keeps <my-icon>/<m-button> in the HTML namespace. For the few ambiguous tags (title, style, script) pass a namespaced node yourself: document.createElementNS("http://www.w3.org/2000/svg", "title").

Testing, types and size

npm test               # vitest, 1322 tests: src, lite, jsx runtimes, presets, ui, flow, router and every example app
npm run test:coverage  # 100% statements/branches/functions/lines are enforced
npm run test:bundles   # re-runs the suites against the built ESM and CJS output
npm run test:types     # type-checks the public api and jsx usage
npm run test:pack      # packs, installs in a clean project, checks exports/files/types
npm run size           # fails when a bundle exceeds its byte budget

Every published entry point ships its own .d.ts — uele, uele/lite, uele/ui, uele/flow, uele/router, uele/reactive, uele/adapters/* and uele/diffs/* — so tsc is happy without a @types package. The package is sideEffects: false (bundlers tree-shake it) and needs Node 18+. Every signal and diff library behind a preset is an optional peer dependency: npm i uele installs two packages, and you add only the library you actually use.

The runtime has no dependencies except usub and stays inside these budgets (raw / gzip):

| bundle | budget | notes | | --- | --- | --- | | dist/index.min.js | ~3.7 kB / ~1.85 kB | full library, iife | | lite/dist/index.min.js | ~2.4 kB / ~1.3 kB | h, add, useLive, props, r | | jsx-runtime/dist/index.es.js | ~0.4 kB / ~0.3 kB | re-exports the shared h |

lite uses usub/tiny, so it supports functions/signals, promises and async iterables, but not subscribe based observables, and it ships no control flow components (If, For, Switch, Dynamic, lazy, map).

The suite is deliberately conformance heavy: the whole runtime is driven against eleven signal libraries (ulive, @preact/signals-core, @vue/reactivity, usignal, alien-signals, s-js, mobx, rxjs, nanostores, @tanstack/store, solid-js) through the api adapter, and map/For/useLive/add are exercised against ten diff engines (swapdom, udomdiff, domdiff, morphdom, nanomorph, fast-array-diff, @thi.ng/diff, list-diff2, a rebuild engine and the built-in marker walk) - including 42 250 exhaustive list-state transitions (test/diff.transitions.test.js). test/dom.cleanup.test.js pins what is detached after every kind of update, and test/memory.test.js measures reachability with a FinalizationRegistry oracle under --expose-gc. npm run test:bundles re-runs the behavioural suites against the built ESM and CJS output, and npm run test:pack installs the tarball in a clean project and exercises both import and require consumers.

Errors

Children that are promises, observables or async iterables never throw into the render: a rejected promise or a stream error is ignored and the previous content (or the placeholder) stays. Subscribe manually with r(source, next, error) when you need to handle errors - it also gives you an unsubscribe function.

Thanks and Inspiration

License

MIT