Radi is a lightweight reactive JSX runtime that lets you write components as plain functions with direct DOM manipulation โ no virtual DOM required.
Radi takes a unique approach compared to React, Preact, or even Solid:
| Feature | React/Preact | Solid | Radi |
|---|---|---|---|
| Virtual DOM | Yes | No | No |
| Props | Object | Object | Function () => props |
| Component execution | Every render | Once | Once |
| Reactivity | Implicit (re-render all) | Signals | Explicit functions |
| Update mechanism | Scheduler | Signals | Native DOM events |
In Radi, components receive props as a getter function, not a plain object:
function Greeting(props: JSX.Props<{ name: string }>) {
// Access props by calling the function
return <h1>Hello, {() => props().name}!</h1>;
}This enables fine-grained reactivity โ when parent state changes, only the specific reactive expressions that read props will update.
Component functions execute exactly once. The returned JSX is the component's template. State lives in closure variables:
function Counter(this: ComponentNode) {
// This code runs once when the component mounts
let count = 0;
return (
<button onclick={() => { count++; update(this); }}>
{() => `Count: ${count}`}
</button>
);
}Wrap any expression in a function to make it reactive. When update() is called
on an ancestor, these functions re-execute:
const view = (
<div>
{/* Static โ never updates */}
<span>Static text</span>
{/* Reactive โ re-runs on update */}
<span>{() => dynamicValue}</span>
{/* Reactive prop */}
<input value={() => inputValue} />
</div>
);Radi uses native DOM events (update, connect, disconnect) instead of a
custom scheduler. Call update(node) to trigger reactive re-evaluation:
function Timer() {
let seconds = 0;
const el = <div>{() => seconds}</div>;
setInterval(() => {
seconds++;
update(el); // Dispatches native "update" event
}, 1000);
return el;
}- Direct DOM manipulation with minimal abstraction
- Fine-grained reactivity via functions
- Lightweight component model with closures for state
- Fragment support (
<Fragment>or<>...</>) - Lifecycle events (
connect,disconnect) - AbortSignal helpers tied to element lifecycle
- Suspense boundaries for async components
- Keyed lists for efficient reconciliation
- TypeScript automatic JSX runtime support
# Deno
deno add jsr:@Marcisbee/radi
# npm
npm install radiimport { createRoot, update } from 'radi';
function App() {
let count = 0;
const root = (
<div>
<h1>Counter App</h1>
<p>Count: {() => count}</p>
<button onclick={() => { count++; update(root); }}>
Increment
</button>
</div>
);
return root;
}
const root = createRoot(document.getElementById('app')!);
root.render(<App />);function MyComponent(
this: ComponentNode, // Host element (the <host> tag)
props: JSX.Props<{ value: number }> // Props getter function
) {
// 1. Setup code runs once
const signal = createAbortSignal(this);
// 2. Event handlers
this.addEventListener('connect', () => {
console.log('Component mounted');
}, { signal });
// 3. Return JSX template
return (
<div>
{/* Reactive expression */}
{() => props().value * 2}
</div>
);
}const list = (
<>
<li>First</li>
<li>Second</li>
</>
);Any function passed as a child is treated as a reactive generator:
const time = () => new Date().toLocaleTimeString();
const clock = <div>The time is: {time}</div> as HTMLElement;
setInterval(() => update(clock), 1000);Props can also be reactive functions:
let isDisabled = false;
const button = (
<button disabled={() => isDisabled}>
Click me
</button>
) as HTMLElement;
// Later...
isDisabled = true;
update(button);Elements receive connect / disconnect events when added/removed from the
document:
const node = (
<div
onconnect={() => console.log('connected')}
ondisconnect={() => console.log('disconnected')}
/>
);Automatically clean up event listeners and subscriptions:
function Component(this: HTMLElement) {
const signal = createAbortSignal(this);
// Automatically removed when component disconnects
window.addEventListener('resize', handleResize, { signal });
return <div>...</div>;
}For cleanup on update or disconnect:
const signal = createAbortSignalOnUpdate(element);
// Aborts when element updates OR disconnectsSkip re-computation when values haven't changed:
const expensiveChild = memo(
() => <ExpensiveComponent data={data} />,
() => data === previousData // Return true to skip update
);For efficient list reconciliation:
import { createList, createKey } from 'radi';
function TodoList(props: () => { items: Todo[] }) {
return (
<ul>
{() => createList((key) =>
props().items.map((item) =>
key(() => <TodoItem item={item} />, item.id)
)
)}
</ul>
);
}Single keyed elements preserve state across updates:
{() => createKey(() => <Editor />, activeTabId)}
// Editor remounts only when activeTabId changesRadi supports async components with Suspense boundaries:
import { Suspense, suspend, unsuspend } from 'radi';
// Async component using Promises
async function UserProfile(props: JSX.Props<{ userId: number }>) {
const response = await fetch(`/api/users/${props().userId}`);
const user = await response.json();
return (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
}
// Wrap in Suspense for loading state
const app = (
<Suspense fallback={() => <div>Loading...</div>}>
<UserProfile userId={123} />
</Suspense>
);Manual suspend/unsuspend for custom async work:
function DataLoader(this: HTMLElement) {
let data = null;
suspend(this);
fetchData().then((result) => {
data = result;
unsuspend(this);
update(this);
});
return <div>{() => data ? renderData(data) : null}</div>;
}{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "radi"
}
}For development with extra source metadata:
{
"compilerOptions": {
"jsx": "react-jsxdev",
"jsxImportSource": "radi"
}
}{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "createElement",
"jsxFragmentFactory": "Fragment"
}
}Then import manually:
import { createElement, Fragment } from 'radi';createRoot(target)โ Create a render rootroot.render(element)โ Render into the rootroot.unmount()โ Unmount and cleanup
update(node)โ Trigger reactive updates on node and descendantsmemo(fn, shouldMemo)โ Memoize reactive expressions
createList(fn)โ Create keyed list for efficient diffingcreateKey(renderFn, key)โ Create single keyed element
createAbortSignal(node)โ AbortSignal that fires on disconnectcreateAbortSignalOnUpdate(node)โ AbortSignal that fires on update or disconnect
Suspenseโ Boundary component with fallbacksuspend(node)โ Signal async work startingunsuspend(node)โ Signal async work complete
createElement(type, props, ...children)โ JSX factoryFragmentโ Fragment symbol
- Fork and clone
- Install dependencies
- Run tests:
deno task test - Open a PR with a concise description
MIT