A small reactive UI library for JavaScript and JSX. Build DOM elements, connect your preferred signal library, and compose reusable components.
npm install ueleExamples below use JavaScript modules with a bundler and a browser DOM. JSX examples also need an automatic JSX transform.
Want a complete app to edit? Start with the Vite + JSX starter: full uele, Preact Signals, a responsive todo app, and working dev/build/preview commands. No React plugin or additional state layer is needed.
No signal library or JSX is needed for this counter:
import { h, useLive } from 'uele/tiny';
const [count, setCount] = useLive(0);
const increment = () => setCount(value => value + 1);
const button = h('button', { onclick: increment }, count);
document.body.append(button);h(tag, props, ...children) creates DOM nodes. useLive(initialValue)
returns renderable nodes and a function that replaces their content.
| Import | Use it for |
|---|---|
uele |
Full renderer; the simplest starting point |
uele/lite |
Compact signal integration and SVG/MathML |
uele/tiny or uele/core |
Minimal renderer; bring your own integration |
There are three variants: core and tiny are aliases, not separate renderers.
Full includes map, f, lazy, and compact swapdom reconciliation.
Lite and full include reactivity support and SVG/MathML rules.
For most apps, start with uele. See the
tier guide for the detailed differences.
Install the signal library you want. For example, Preact Signals:
npm install @preact/signals-coreimport { h } from 'uele';
import { signal } from '@preact/signals-core';
import { preactSignals } from 'uele/signals/preact';
preactSignals();
const count = signal(0);
const increment = () => count.value++;
const button = h('button', { onclick: increment }, count);
document.body.append(button);Install a preset once, before creating state and rendering. Other options
include uele/signals/ulive, uele/signals/vue, and more. Install only the
signal package you use.
All 19 signal presets
Import installers from uele/signals/<suffix>.
| Suffix → installer | Signal package |
|---|---|
alien-signals → alienSignals() |
alien-signals |
anod → anod() |
anod |
lite-signal → liteSignal() |
@zakkster/lite-signal |
maverick → maverick() |
@maverick-js/signals |
mobx → mobx() |
mobx |
nanostores → nanostores() |
nanostores |
oby → oby() |
oby |
preact → preactSignals() |
@preact/signals-core |
reactively → reactively() |
@reactively/core |
reflex → reflex() |
@volynets/reflex |
rescript-signals → rescriptSignals() |
rescript-signals, @rescript/runtime |
rxjs → rxjs() |
rxjs |
signals → signals() |
@webreflection/signals |
sjs → sjs() |
s-js |
solid → solid() |
solid-js |
tanstack → tanstackStore() |
@tanstack/store |
ulive → ulive() |
ulive |
usignal → usignal() |
usignal |
vue → vue() |
@vue/reactivity |
Exact install commands and runnable usage for every signal preset.
Pass a signal directly, or use a function for derived content:
import { h } from 'uele';
import { signal } from '@preact/signals-core';
import { preactSignals } from 'uele/signals/preact';
preactSignals();
const name = signal('World');
const greeting = h('p', null, () => `Hello, ${name.value}!`);
document.body.append(greeting);
name.value = 'uele';Functions re-run reactively only with an effect-capable preset, such as Preact or ulive. Subscribe-only libraries do not track arbitrary function reads.
Full uele can bind signals, resolve promises into DOM content, and create SVG in the same view—no separate component model or virtual DOM is needed:
import { h } from 'uele';
import { signal } from '@preact/signals-core';
import { preactSignals } from 'uele/signals/preact';
preactSignals();
const count = signal(0);
const app = h('main', null,
h('button', { onclick: () => count.value++ }, count),
h('p', { id: 'status' }, Promise.resolve('Ready')),
h('svg', { viewBox: '0 0 24 24', width: 48 },
h('circle', { cx: 12, cy: 12, r: () => count.value + 5 })
)
);
document.body.append(app);Clicking updates the counter and circle radius. The promise fills its own content region when it resolves. Full also supports observable sources and async iterables; use renderer-owned teardown for views that are unmounted.
Point your compiler at uele's automatic JSX runtime. For TypeScript, the
relevant tsconfig.json settings are:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "uele"
}
}Save JSX code in a .jsx or .tsx file:
/** @jsxImportSource uele */
import { signal } from '@preact/signals-core';
import { preactSignals } from 'uele/signals/preact';
preactSignals();
const count = signal(0);
const Counter = () => (
<button onclick={() => count.value++}>
Count: {count}
</button>
);
document.body.append(<Counter />);Components are functions that return DOM nodes. They receive their children
as an array. Extract children before forwarding props to a DOM element:
import { h } from 'uele';
const Box = ({ children, ...attrs }) =>
h('section', attrs, children);
const app = h(Box, { id: 'greeting' }, 'Hello!');
document.body.append(app);Use positional children with h(), not h('div', { children: ... }).
The JSX runtime handles this conversion for you.
For renders a reactive list. Use stable, unique keys for object items:
/** @jsxImportSource uele */
import { For } from 'uele/flow';
import { signal } from '@preact/signals-core';
import { preactSignals } from 'uele/signals/preact';
preactSignals();
const items = signal([{ id: 1, text: 'Learn uele' }]);
const app = (
<ul>
<For each={items} key={item => item.id}>
{item => <li>{item.text}</li>}
</For>
</ul>
);
document.body.append(app);
items.value = [...items.value, { id: 2, text: 'Build an app' }];If, Show, Switch, Match, and Dynamic also come from uele/flow.
See the control-flow reference.
Choose a complete, runnable file rather than copying a large app from here:
- Preact todo: add, toggle, delete, filter, clear completed, and remaining count.
- Simple State todo: no signal package or preset; uses your callback-based State function and a small subscription bridge.
- Tiny ulive todo: the measured small-app example.
- All todo examples and instructions.
Use ordinary DOM properties, event handlers, and style/class objects:
import { h } from 'uele';
const button = h('button', {
type: 'button',
class: { primary: true },
style: { color: 'white', background: 'royalblue' },
onclick: event => {
event.currentTarget.textContent = 'Clicked!';
}
}, 'Click me');
document.body.append(button);Signals can also drive attributes, properties, individual style values, and class flags. For optional form bindings, DOM actions, and other prop handlers, see the props guide.
These modules work with the configured main renderer and signal preset:
uele/ui— buttons, forms, layout, dialogs, tables, and other UI primitives. See the component examples.uele/hooks— fetch, timers, forms, storage, browser events, and more. See the hooks guide.uele/themes— theme tokens, styles, andThemeProvider. See the themed sample pages.uele/router—createRouter,Router,Link, and navigation. See the routing guide.uele/flow— lists, conditional views, async boundaries, portals, and titles. See flow behavior and tests.
Use your signal library's native API, or use uele/reactive when writing
components that should work across different signal libraries:
import { h } from 'uele';
import { preactSignals } from 'uele/signals/preact';
import { signal, read, write } from 'uele/reactive';
preactSignals();
const count = signal(0);
const increment = () => write(count, read(count) + 1);
const button = h('button', { onclick: increment }, count);
document.body.append(button);This is an authoring facade, not another signal engine. It delegates to
the installed preset. computed and effect need a capable preset; check
canCompute() and canEffect() if your code supports subscribe-only stores.
Full uele uses usub internally to read and subscribe to reactive/async sources. You do not need to import usub to use these examples. More about configuration.
UStor is optional. It lets you update object properties directly and define computed getters.
npm install ustor@^0.3.0 @preact/signals-coreConnect UStor to the same signal library as uele, then use a normal store:
import { api, h } from 'uele';
import { preactSignals } from 'uele/signals/preact';
import { store, api as storeApi } from 'ustor';
preactSignals();
const { signal, get, set, is, computed } = api;
Object.assign(storeApi, {
signal, get, set, is, computed, getComputed: get
});
const state = store({
count: 0,
get double() { return this.count * 2; }
});
const app = h('section', null,
h('button', {
onclick: () => state.count++
}, 'Increment'),
h('output', { 'data-count': '' }, () => state.count),
h('output', { 'data-double': '' }, state.$double)
);
document.body.append(app);Use () => state.count or state.$count for live rendering. Reading
state.count directly while creating a node gives you a snapshot.
No region() helper is needed. The larger examples use useLive directly
when they need an explicit dispose() method; see
how unmounting works.
The runnable UStor cart combines:
- Nested state:
item.quantity++immediately updates its quantity label. - Computed totals: the store's
totalgetter updates the displayed sum. - Live arrays:
push,splice, andreverseupdate a keyedForlist. - Retained DOM: reordering keeps the existing keyed row nodes.
- Explicit teardown:
dispose()stops the view's owned subscriptions.
No JSX, virtual DOM, named region helper, or extra diff package is required. The example uses full uele's default reconciliation. See the cart demo and setup and advanced integration details for Vue and store limitations.
Use useLive when you need to replace or unmount a whole view:
import { h, useLive } from 'uele';
const [view, replace] = useLive(h('p', null, 'Hello'));
document.body.append(...view);
replace(h('p', null, 'Updated'));
replace(null); // unmount and stop owned subscriptionsFor also owns the subscriptions in rows it removes. Arbitrary native DOM
removal such as element.remove() is not a renderer lifecycle signal.
See ownership details before writing custom teardown code.
Most apps need no setup: full uses compact swapdom/deflate; core/tiny and
lite use basic marker replacement. Pick another engine only when needed.
| Preset path suffix | Additional package |
|---|---|
builtin |
None; reset to basic replacement |
swapdom |
swapdom |
udomdiff |
udomdiff |
domdiff |
domdiff |
morphdom |
morphdom |
nanomorph |
nanomorph |
fast-array-diff |
fast-array-diff |
rebuild |
None |
spect |
None |
snabbdom |
None |
heckel |
None |
list-difference |
None |
stage0 |
None |
The five dependency-free algorithms spect, snabbdom, heckel,
list-difference, and stage0 are inspired by
js-diff-benchmark.
They are independent node-identity implementations, not framework wrappers.
For example, select stage0 once, before rendering:
import { stage0 } from 'uele/diffs/stage0';
stage0();The explicit swapdom preset uses upstream's larger inflate variant, not
full's compact default. builtinDiff() from uele/diffs/builtin resets
any entry to basic replacement. See all diff preset recipes.
Keep the first app simple. These guides cover the advanced topics when needed:
- API, configuration, and naming
- Renderer tiers,
map, and lower-level hooks - Writing custom props, signal adapters, and diff engines
- Adapter contracts and compatibility pitfalls
- Props and form bindings
- Hook reference
- All runnable examples
- Production-readiness review and known advisory
For contributors, npm ci followed by npm run test:release runs the
coverage, core, ESM/CJS, types, installed-consumer, size, and browser checks.
Linux may need Chromium system libraries; see the CI workflow.
usub, swapdom, and the signal and diff libraries listed in the preset guide.
| Runnable example | Complete JS, gzip |
|---|---|
| Hello World | 966 B (<1 kB) |
| Stateful counter | 1,051 B (<1.4 kB) |
| Signal counter with ulive | 1,248 B (<1.4 kB) |
| Signal todo: add, toggle, delete | 1,473 B (<1.8 kB) |
Measured on the current build with Rollup + Terser; decimal kB. These use the public tiny/core entries, not the full entry. Counts include app code, renderer, signal integration, and signal dependency where used. HTML and HTTP headers are excluded. Both default gzip and level 9 pass the limits.
The exact measured bundles run in Chromium without extra JavaScript loads. Sources, methodology, and reproduction. Map/For and every flow component: stress-test coverage.