Radi is a lightweight reactive JSX runtime that lets you write components as plain functions with direct DOM manipulation โ no virtual DOM required.
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.
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
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.
MIT