radi

Radi

Radi is a lightweight reactive JSX runtime that lets you write components as plain functions with direct DOM manipulation โ€” no virtual DOM required.

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

Installation

# Deno
deno add jsr:@Marcisbee/radi

# npm
npm install radi

Basic 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 disconnects

Memoization

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 changes

Async 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

{
  "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

Reactivity

Lists

Lifecycle

Suspense

JSX

Contributing

  1. Fork and clone
  2. Install dependencies
  3. Run tests: deno task test
  4. Open a PR with a concise description

License

MIT