Framework
Marcisbee/radi avatar
Marcisbee/radi

Radi: a JSX runtime where props are functions and components run once

🌀Tiny (in size) front-end framework with no extra browser re-flows

945 stars28 forksTypeScriptMIT

At a glance

What is it?
Radi is a small reactive JSX runtime for TypeScript and JavaScript that skips the virtual DOM entirely. Props arrive as a getter function, component functions execute once and keep state in closures, and updates are dispatched as native DOM events you trigger by hand, which is the source of both its speed story and its learning curve.
Who is it for?
Adopt Radi when you want a JSX runtime with no virtual DOM, no scheduler and explicit control over when a subtree re-evaluates, and you are prepared to wrap reactive expressions in functions and call update() yourself. Do not adopt it for a team that wants automatic dependency tracking, since Radi has no signal system, and treat the maintenance state as a real risk given the last published release is v0.4.2 from 2018-09-09 while the last push was on 2026-05-08.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 15 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on November 7, 2026, and from our analysis. They are not legal advice.

From daily snapshots

Star history

  • Marcisbee/radi945 ★ · +1
Star history
Repository2026-10-212026-11-07
Marcisbee/radi944945

1 Stars in the last 17 days, measured from the daily GitHub snapshot. Compare with other projects

Editorial analysis

Props are a getter function, not an object

The one design decision that shapes everything else in Radi is that a component receives its props as a function. The README contrasts this with React and Preact, where props are an object, and with Solid, where props are also an object. In Radi the component calls the props function to read a value:

ts
function Greeting(props: JSX.Props<{ name: string }>) {
  // Access props by calling the function
  return <h1>Hello, {() => props().name}!</h1>;
}

Reactivity is fine grained because of that shape. When parent state changes, only the specific reactive expressions that read props update, and the README describes this as the mechanism rather than a side effect. The cost is visible in the code above: a plain object would have given props.name, and here the read has to be wrapped in a function so the runtime knows to re-evaluate it. Every piece of dynamic data in a Radi component therefore carries a small syntactic tax, which is the trade the whole library is built around.

The table in the README states the summary plainly: no virtual DOM, props as function, component execution once, reactivity through explicit functions, and updates through native DOM events. Every other feature in the library follows from that combination.

Component functions execute once and hold state in closures

The second structural choice is that a component body runs exactly once. The JSX it returns is the template, and anything that changes over time is a reactive expression evaluated later. State therefore lives in ordinary closure variables rather than in a hook, a store or a signal:

ts
function Counter(this: ComponentNode) {
  // This code runs once when the component mounts
  let count = 0;

  return (
    <button onclick={() => { count++; update(this); }}>
      {() => `Count: ${count}`}
    </button>
  );
}

The `this` binding is the host element, the tag the component is attached to, and it is what the component passes to update() when it wants to re-render. Since the body never re-runs, an expensive setup cost is paid once, and a component that reads an expensive value needs the memo helper rather than a re-executed body.

The mental model shift for someone arriving from React is the closure. With hooks, state is declared through a call and the component body re-runs to pick it up. Here the variable simply persists, and the button handler increments it directly before asking the runtime to refresh. That is less machinery, and it is also less ceremony than a hook, but it means there is no built-in place to look for the state of a component and no built-in way to run code on every render.

Updates are native DOM events you dispatch yourself

Radi does not run a scheduler. Instead it uses native DOM events, and the README names the three of them: update, connect and disconnect. Calling update(node) triggers reactive re-evaluation for that node and its descendants, and the mechanics are simple enough to read in one function:

ts
function Timer() {
  let seconds = 0;
  const el = <div>{() => seconds}</div>;

  setInterval(() => {
    seconds++;
    update(el); // Dispatches native "update" event
  }, 1000);

  return el;
}

The re-evaluation is granular in the sense that it re-runs the reactive functions under that node, so a call to update touches the expressions you wrapped in functions rather than the whole document. That is the mechanism behind the project's claim of avoiding extra browser re-flows, since the DOM is touched directly rather than diffed and patched.

What you give up is automatic dependency tracking. Nothing runs because a variable changed. Something has to call update, and if you forget, the view shows stale data with no error. The library is honest about this by making update a plain import rather than hiding it, and the documentation examples always show it next to the mutation, but it is a real difference in failure mode from a system that re-renders on every state change.

Two installs and one line of TypeScript configuration

Radi ships to two package registries, and the README gives both commands:

bash
# Deno
deno add jsr:@Marcisbee/radi

# npm
npm install radi

The Deno entry points at the jsr scope, and the repository carries a deno.json and a deno.lock next to a build.npm.ts script, so the same source is published to npm through a build step rather than maintained twice.

For TypeScript users the JSX configuration is required, and the recommended mode is the automatic runtime with the import source pointed at the library:

json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "radi"
  }
}

A development variant uses react-jsxdev for extra source metadata, and a manual mode with jsx set to react is also documented. The automatic runtime matters here because Radi components are plain functions, not classes, and the JSX factory has to come from Radi rather than from React. The repository also holds a playground/ directory, which is the quickest way to see the API used in a real project layout without wiring a build first.

Cleanup is delegated to AbortSignal helpers tied to element lifecycle

A component that adds a listener needs that listener removed when the component leaves the document, and Radi handles this with AbortSignal helpers bound to the element lifecycle. The pattern is that a signal is created from the element, passed as the signal option on addEventListener, and the runtime aborts it when the element disconnects:

ts
function Component(this: HTMLElement) {
  const signal = createAbortSignal(this);

  // Automatically removed when component disconnects
  window.addEventListener('resize', handleResize, { signal });

  return <div>...</div>;
}

A second helper, createAbortSignalOnUpdate, produces a signal that aborts when the element updates or disconnects, which covers the case where a subscription is only valid for one version of the data.

The same lifecycle story appears in the component events. Elements receive connect and disconnect events when they are added to or removed from the document, and the component anatomy example registers a connect handler to log a mount message, with the signal passed in so the handler is cleaned up with everything else. The pattern is native web platform rather than a framework-specific hook system, and it composes with existing code that already uses AbortController.

Keyed lists, memoization and Suspense cover the harder cases

Three features handle the cases where naive re-evaluation would be wasteful or wrong.

Memoization skips re-computation when values have not changed, using a predicate that returns true to skip the update. Keyed lists exist because a component's state lives in a closure, so an element that is replaced rather than updated loses that state; createList and createKey preserve it, and the README notes that a single keyed element remounts only when its key changes. Suspense handles async components with a fallback, and there is also a manual pair, suspend and unsuspend, for custom async work where the data arrives outside the component's own promise.

ts
import { Suspense, suspend, unsuspend } from 'radi';

// Wrap in Suspense for loading state
const app = (
  <Suspense fallback={() => <div>Loading...</div>}>
    <UserProfile userId={123} />
  </Suspense>
);

The feature list also includes fragments, either as a Fragment component or as an empty fragment pair, and a claim of TypeScript automatic JSX runtime support. Fragments and keyed lists are the two that matter most in practice, since they decide how much of a tree gets rebuilt when state changes.

Releases stopped in 2018 while pushes continued into 2026

The version history of the repository is the first thing to check, because it is unusual. The three most recent releases are v0.4.0 on 2018-09-07, v0.4.1 on 2018-09-09 and v0.4.2 on 2018-09-09. Two of them landed on the same day. After that, the release list stops, and the repository moved on to a different publishing route: a Deno and jsr based workflow with deno.json and deno.lock in the tree, a build.npm.ts script for the npm side, and a check-size.ts script, which is a deliberate statement that the library stays small.

The repository is not archived, and the last push was on 2026-05-08, so the code is being worked on. What that does not tell you is which version a consumer of the npm package or the jsr package actually gets. The release tags stop in 2018, and the version in the published package is not stated in the README, so the mapping between the current source and the published artefact has to be checked on the registry rather than read off the release page.

The licence is MIT, with a LICENSE file at the root, so there is no copyleft obligation for a product that depends on it. The remaining files in the tree, including an AGENTS.md file and a tsconfig.json, suggest a repository that is set up for automated contribution workflows as well as for people editing it by hand.

Editorial conclusion

Adopt Radi when you want a JSX runtime with no virtual DOM, no scheduler and explicit control over when a subtree re-evaluates, and you are prepared to wrap reactive expressions in functions and call update() yourself. Do not adopt it for a team that wants automatic dependency tracking, since Radi has no signal system, and treat the maintenance state as a real risk given the last published release is v0.4.2 from 2018-09-09 while the last push was on 2026-05-08. Verify first that the version you install on npm or on jsr carries the features you need, then read the LICENSE file, since the project is MIT licensed and carries a check-size.ts script that states its own size budget.

Frequently asked questions

How do I install the Radi JSX runtime?

On Deno the README gives deno add jsr:@Marcisbee/radi, and on npm it gives npm install radi. TypeScript projects also need jsx set to react-jsx and jsxImportSource set to radi.

How do I trigger a re-render in Radi?

You call update(node), which dispatches a native update event and re-evaluates the reactive expressions that depend on the changed state. Reactive expressions are functions, so a plain expression such as {count} stays static while {() => count} updates.

Does Radi use a virtual DOM?

No. The README states that Radi has no virtual DOM and manipulates the DOM directly, with updates delivered as native DOM events rather than through a scheduler.

Is Radi still maintained?

The repository is not archived and the last push was on 2026-05-08, but the listed releases end at v0.4.2 from 2018-09-09. Check the version published on npm or on jsr before assuming the release page reflects the current source.

Official sources

  1. License: MIT
  2. Marcisbee/radi on GitHub
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/marcisbee-radi.svg)](https://hysenlabs.com/projects/marcisbee-radi)

Star history chart for your README

markdown
[![Star history](https://hysenlabs.com/chart/marcisbee-radi.svg)](https://hysenlabs.com/projects/marcisbee-radi)

Was this useful?