@knno/jsx
v2.5.1
Published
A lightweight fine-grained reactive JSX runtime — no virtual DOM
Maintainers
Readme
@knno/jsx
A lightweight JSX runtime with fine-grained reactivity — no virtual DOM.
FAQ available at FAQ.md — batching, event handlers,
{count}vs{() => count()}, derived signals, and more.
Quick Start
import { render, signal } from '@knno/jsx';
function Counter() {
const [count, setCount] = signal(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count.value + 1)}>+1</button>
</div>
);
}
render(() => <Counter />, '#app');Why @knno/jsx?
refreturns the raw HTMLElement — no proxy, pass it directly to D3, Monaco, Mapbox, etc..valuereads without tracking — safe in event handlers,requestAnimationFrame, timersskipUpdateDOM— subscribe to signals but manage DOM yourself (Canvas, WebGL)- No virtual DOM — your direct DOM manipulations are never overwritten by framework re-renders
Packages
| Package | Description |
|---------|-------------|
| @knno/jsx | Core — signals, JSX runtime, effects, array editor, SSR |
| @knno/jsx-router | Hash-based reactive router |
| @knno/jsx-optimizer | Compile-time optimizer (Vite, Rollup, esbuild, tsup) |
npm install @knno/jsx # core
npm install @knno/jsx-router # optional, routing
npm install -D @knno/jsx-optimizer # optional, production optimizationSetup
// tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@knno/jsx"
}
}Signals
const [count, setCount] = signal(0);
count() // read + subscribe (inside JSX, effects, track)
count.value // read only, no subscription (inside event handlers)
setCount(1) // update valueReactive Expressions
Wrap expressions in a function to make them reactive:
<span>{() => `Count: ${count()}`}</span>
<input value={() => count()} />
<div class={() => count() > 5 ? 'highlight' : ''} />Pass a getter directly for simple reads:
<span>{count}</span> // same as {() => count()}
<input value={count} />Events
Use .value inside event handlers — it reads without creating a subscription:
<button onClick={() => setCount(count.value + 1)}>+1</button>Effects
effect(() => {
console.log('Count:', count());
return () => console.log('cleanup'); // runs before next execution or on destroy
});Array Rendering
import { arraySignal, each } from '@knno/jsx';
const [items, setItems, editor] = arraySignal<string>([]);
<ul>
{each(items, (item, _value, index) => (
<li>
{item}
<button onClick={() => editor.remove(index())}>✕</button>
</li>
))}
</ul>Editor Methods
| Method | Description |
|--------|-------------|
| push(...items) / pop(count?) | Append / remove from end |
| insert(index, ...items) | Insert at position |
| remove(index, count?) | Remove |
| move(from, to, count?) | Move |
| update(index, item) | Replace entire row |
| update(index, mutator, fields) | Mutate specific fields in-place |
| update(index, item, true) | Replace data, keep DOM (partial) |
Field-Level Updates
type User = { name: string; age: number };
const [users, , editor] = arraySignal<User>([{ name: 'Alice', age: 25 }]);
<li>
<span>{() => value('name', (r) => r.name)}</span>
<span>{() => value('age', (r) => r.age)}</span>
</li>
// Triggers only the 'age' subscription — no DOM rebuild
editor.update(0, (u) => { u.age++ }, ['age']);Reconciliation
Pass a keyFn for smart diffing via setData(newArray):
const [items, setItems] = arraySignal<User>([], (u) => u.id);
// Items with same keys are moved, not rebuilt
setItems(fetchUsers());Without keyFn, setData does a full replace (destroy all, create all).
ref Cleanup
Return a cleanup function from ref — it runs automatically when the element is removed:
<div ref={el => {
const id = requestAnimationFrame(draw);
return () => cancelAnimationFrame(id);
}} />Context
const Theme = createContext('light');
// Provider (function form or JSX component form)
function App() {
const [theme, setTheme] = signal('dark');
// Function form — recommended for reactive values with nesting
return Theme.provide(theme, () => <Sidebar />);
// JSX form — children must be a render function
// return <Theme.Provider value={theme}>{() => <Sidebar />}</Theme.Provider>;
}
// Consumer
function Sidebar() {
const theme = Theme.use(); // returns a signal getter
return <div style={() => ({ bg: theme() === 'dark' ? '#222' : '#eee' })} />;
}Server-Side Rendering
import { JSDOM } from 'jsdom';
import { render, useWindowContext } from '@knno/jsx';
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
useWindowContext(dom.window, () => {
render(() => <h1>Hello SSR!</h1>);
});Namespaces
import { NS } from '@knno/jsx';
NS.svg(() => (
<svg><circle cx="50" cy="50" r="40" fill="red" /></svg>
));Compile-Time Optimizer
npm install -D @knno/jsx-optimizer// vite.config.ts
import { optimizer } from '@knno/jsx-optimizer/vite';
export default { plugins: [optimizer()] };Replaces runtime JSX dispatch with template cloning. ~15% perf boost. Supports Vite, Rollup, esbuild, tsup.
Utility Functions
import { cls, unsafe } from '@knno/jsx';
cls('btn', isActive && 'active') // 'btn active'
cls('btn', { active: isActive, disabled: isErr }) // 'btn active'
unsafe('<strong>bold</strong>') // DocumentFragment
<input prop:value={val} /> // element.value = val (bypasses setAttribute)API Reference
| Function | Signature | Notes |
|----------|-----------|-------|
| signal(v) | <T, C>(v: T) => [SignalGetter<T>, SignalSetter<T, C>] | C is the change descriptor type for typed editors |
| effect(fn) | (fn: () => T \| (() => void)) => void | Cleanup returned from fn runs before re-execute or on destroy |
| derived(fn) | <T>(fn: () => T) => SignalGetter<T> | Purely computational — no cleanup support |
| track(owner, fn) | (owner: Element, fn: (ctx?: RenderCtx) => unknown) => [Subscription, unknown, boolean] | Returns [sub, firstResult, skipUpdateDOM]. Unlike effect(), track() runs synchronously and returns the result. |
| render(fn, target?) | (fn, target?: string \| HTMLElement) => () => void | target is a CSS selector or HTMLElement. Returns a destroy function that removes all DOM and subscriptions. |
| arraySignal(v, keyFn?) | <T>(v: T[], keyFn?: (item: T) => string \| number) => [SignalGetter<T[]>, setter, ArrayEditor<T>] | keyFn enables reconciliation on setData(newArray) |
| each(getter, renderFn) | <T>(getter: SignalGetter<T[]>, renderItem: (item: T, value: ValueAccessor<T>, index: IndexAccessor) => Node) => (ctx: RenderCtx) => Node[] | index() is reactive — updates on insert/remove/move |
| createContext(v) | <T>(v: T) => Context<T> | Returns { id, defaultValue, Provider, provide, use } |
| onError(handler) | (handler: ((error: unknown, info: { owner: Element \| null; sub: Subscription }) => void) \| null) => void | info.owner is the DOM element owning the subscription; info.sub is the raw Subscription object |
| useWindowContext(ctx, fn) | <R>(ctx: WindowContext, fn: () => R) => R | Restores previous context on return (try/finally). Nested calls work. No hydration support — SSR renders static HTML only. |
| cls(...args) | (...args: (string \| number \| boolean \| null \| undefined \| string[] \| Record<string, boolean \| null \| undefined>)[]) => string | Does NOT support nested arrays. cls(['a', ['b']]) flattens one level only. |
License
MIT © Thor Qin
