Radi
Radi is a lightweight reactive JSX runtime that lets you write components as plain functions with direct DOM manipulation — no virtual DOM required.
Start a project
With Node.js 22+ installed, choose a starter:
npm create radi@latest -- my-app --template spa
npm create radi@latest -- my-server --template ssr
npm create radi@latest -- my-site --template ssg
npm create radi@latest -- my-go-app --template goThen enter the generated directory, run npm install, and run npm run dev.
Open http://127.0.0.1:3000. The generator refuses nonempty directories and
never installs dependencies automatically. Use npm create radi@latest -- --help for syntax.
| Template | Result | Production build |
|---|---|---|
spa |
Browser-rendered app with a reactive counter | Static dist/ |
ssr |
Node HTTP server rendering HTML per request | dist/ plus npm dependencies |
ssg |
Two pages generated at build time | Static dist/ |
go |
Go HTTP server rendering TSX in embedded V8 | Standalone server binary |
The Go starter additionally requires Linux amd64 with glibc, Go 1.26.10,
Clang 21, and CGO. Follow its generated README before building.
All starters include npm run build and npm start; their READMEs explain
editing, previewing, and deployment. The generator and templates ship inside
the create-radi npm package, published alongside the matching Radi release.
Radi also supports synchronous server rendering and ordered async HTML streaming. See server rendering for APIs, runtime examples, limitations, and reproducible performance measurements.
For a Go HTTP server with embedded V8, async Flop access and TSX components, see the Go adapter.
For lazy interactions on existing server DOM, see resumption. Explicit boundaries carry JSON state and load named handlers on the first click.
For documentation and other static sites with interactive islands, see static site generation.
For SPA navigation, lazy route modules, loaders, and actions, see client routing and route data.
What Makes Radi Different
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 |
Props Are Functions
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.
Components Are Stateful (Run Once)
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>
);
}Reactive Places Are Functions
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>
);Updates Use Native Events
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;
}Features
- 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
Installation
# Deno
deno add jsr:@Marcisbee/radi
# npm
npm install radiBasic Usage
import { 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 />);Component Anatomy
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>
);
}Fragments
const list = (
<>
<li>First</li>
<li>Second</li>
</>
);Reactive Children
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);Reactive Props
Props can also be reactive functions:
let isDisabled = false;
const button = (
<button disabled={() => isDisabled}>
Click me
</button>
) as HTMLElement;
// Later...
isDisabled = true;
update(button);Lifecycle Events
Elements receive connect / disconnect events when added/removed from the
document:
const node = (
<div
onconnect={() => console.log('connected')}
ondisconnect={() => console.log('disconnected')}
/>
);AbortSignal Helpers
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 disconnectsMemoization
Skip re-computation when values haven't changed:
const expensiveChild = memo(
() => <ExpensiveComponent data={data} />,
() => data === previousData // Return true to skip update
);Keyed Lists
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 changesAsync Components & Suspense
Radi 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>;
}TypeScript JSX Configuration
Automatic Runtime (Recommended)
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "radi"
}
}For development with extra source metadata:
{
"compilerOptions": {
"jsx": "react-jsxdev",
"jsxImportSource": "radi"
}
}Manual Mode
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "createElement",
"jsxFragmentFactory": "Fragment"
}
}Then import manually:
import { createElement, Fragment } from 'radi';API Reference
Rendering
createRoot(target)— Create a render rootroot.render(element)— Render into the rootroot.unmount()— Unmount and cleanup
Reactivity
update(node)— Trigger reactive updates on node and descendantsmemo(fn, shouldMemo)— Memoize reactive expressions
Lists
createList(fn)— Create keyed list for efficient diffingcreateKey(renderFn, key)— Create single keyed element
Lifecycle
createAbortSignal(node)— AbortSignal that fires on disconnectcreateAbortSignalOnUpdate(node)— AbortSignal that fires on update or disconnect
Suspense
Suspense— Boundary component with fallbacksuspend(node)— Signal async work startingunsuspend(node)— Signal async work complete
JSX
createElement(type, props, ...children)— JSX factoryFragment— Fragment symbol
Contributing
For reproducible browser performance comparisons with React, Preact, Vue, and vanilla JavaScript, see the comparison benchmark suite.
- Fork and clone
- Install dependencies
- Run tests:
deno task test - Open a PR with a concise description
For publishing to npm, see the release workflow.
License
MIT