On this page
GitHub ↗

Introduction

Radi is a lightweight reactive JSX runtime. Write components as plain functions and update the DOM directly.

Installation

Create a project

Open a terminal with Node.js 22+ and npm installed. Allow about 5 minutes for the SPA setup, depending on download speed.

Step 1: Create your app

Terminal
npm create radi@latest -- my-app --template spa

Use a new or empty directory. The generator refuses nonempty destinations and does not install dependencies.

Step 2: Install dependencies

Terminal
cd my-app
npm install

Step 3: Start the development server

Terminal
npm run dev

Step 4: Open your app

Open http://127.0.0.1:3000. Your Radi app is running. Edit app.tsx, save, and reload the browser to see your change.

Choose another template

Replace spa in the create command with the template you need:

  • spa — a browser-rendered app. Start here if you are unsure.
  • ssr — HTML rendered by a Node server for each request.
  • ssg — HTML generated at build time for a static host.
  • go — a Go server with embedded V8 for rendering.

The Go starter also requires Linux amd64 with glibc, Go 1.26.10, Clang 21, and CGO enabled. Follow its generated README.md to set the compiler environment before running npm run dev.

Each generated README.md explains editing, building, and deployment for that template.

Use an existing project

Already created a starter? Skip this manual setup. To add Radi to an existing project, follow the steps below. The reference describes the current repository API; check exports and types against your installed version before using newer features.

Add the package

Terminal
npm install radi

Configure JSX

Use a bundler that supports JSX. Merge these options into your TypeScript configuration.

tsconfig.json
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "radi",
    "types": ["./node_modules/radi/jsx.d.ts"]
  }
}

The types entry above loads Radi’s browser JSX declarations. Its JSX attributes are permissive, so type checking does not guarantee valid HTML or correct runtime behavior.

The signal, channel, renderer, types and symbols entries below describe source-checkout APIs. Check your package exports before importing those subpaths. The application recipes use the public client API.

Mount your first component

Add a <div id="app"></div> to your HTML, then mount a component from your browser entry.

main.tsx
import { createRoot } from 'radi';

function App() {
  return <h1>Hello, Radi.</h1>;
}

const root = createRoot(document.getElementById('app')!);
root.render(<App />);

Components

A component runs once when it mounts. Keep state in its closure. Props arrive as a getter; call it inside a reactive expression to read the latest value.

greeting.tsx
function Greeting(props: () => { name: string }) {
  return <h1>Hello, {() => props().name}!</h1>;
}

Reactivity

Wrap dynamic children or attributes in functions. Call update on their ancestor to evaluate those functions again. Static content stays put.

counter.tsx
import { update } from 'radi';

function Counter() {
  let count = 0;
  const view = (
    <button onclick={() => { count++; update(view as HTMLElement); }}>
      Count: {() => count}
    </button>
  );
  return view;
}

The HTMLElement assertion narrows Radi’s broad JSX return type for a known HTML element. It does not convert the value. Components run once; destructuring props or returning count without a function captures only the initial value.

Lifecycle

Elements receive connect and disconnect events. Tie subscriptions to the component lifecycle with an abort signal.

Lifecycle
import { createAbortSignal } from 'radi';

function Panel(this: HTMLElement) {
  const signal = createAbortSignal(this);
  window.addEventListener('resize', () => {
    // Respond to the new viewport.
  }, { signal });
  return <div>Ready when you are.</div>;
}

Lists and local state

Use stable IDs, never array positions or random keys. A retained key keeps its instance; its factory may not run again. Read replacement data through a getter instead of capturing the first item.

Editable rows
import { createList, update } from 'radi';

type Item = { id: string; name: string };
function Row(props: () => { read: () => Item }) {
  let draft = '';
  return <li>
    <span>{() => props().read().name}</span>
    <input aria-label="Draft" value={() => draft}
      oninput={(event: Event) => {
        draft = (event.target as HTMLInputElement).value;
      }} />
  </li>;
}

function List() {
  let items: Item[] = [{ id: 'a', name: 'Apple' }, { id: 'b', name: 'Pear' }];
  let byId = new Map(items.map(item => [item.id, item]));
  function replace(next: Item[]) {
    items = next;
    byId = new Map(items.map(item => [item.id, item]));
    update(view as HTMLElement);
  }
  const view = <section>
    <button onclick={() => replace(items.map(item => ({
      ...item, name: item.name + '!',
    })))}>Replace data</button>
    <button onclick={() => replace([...items].reverse())}>Reverse</button>
    <button onclick={() => replace(items.slice(1))}>Remove first</button>
    <ul>{() => createList(key => items.map(({ id }) =>
      key(() => <Row read={() => byId.get(id)!} />, id)
    ))}</ul>
  </section>;
  return view;
}

Type a draft, replace data, then reverse: labels change and drafts follow their IDs. Removing a row discards its local state. Use createKey for one stateful branch; change its key only when you want a reset.

Forms and shared state

Use native labels, validation and submit events. Read value or checked from the control, change state, then update the smallest view that displays it. Event handlers do not trigger updates automatically.

Validated form
import { update } from 'radi';

function Profile() {
  let message = '';
  const view = <form onsubmit={(event: SubmitEvent) => {
    event.preventDefault();
    const form = event.currentTarget as HTMLFormElement;
    const name = String(new FormData(form).get('name') ?? '').trim();
    message = name ? 'Hello, ' + name : 'Enter a name.';
    update(view as HTMLElement);
  }}>
    <label>Name <input name="name" required /></label>
    <button type="submit">Continue</button>
    <p role="status">{() => message}</p>
  </form>;
  return view;
}

This example only validates locally. For saving, show pending/error/success states, prevent duplicate submissions, and validate again on the server. Catch failures where the operation starts; Suspense shows loading, not an application error boundary.

Keep state in the closest shared owner and pass getters and action callbacks to children. Use a channel when a DOM-scoped provider is more convenient.

Shared owner
import { update } from 'radi';

function Count(props: () => { read: () => number; increment: () => void }) {
  return <button onclick={() => props().increment()}>{() => props().read()}</button>;
}
function SharedCounter() {
  let count = 0;
  const read = () => count;
  const increment = () => { count++; update(view as HTMLElement); };
  const view = <section>
    <Count read={read} increment={increment} />
    <Count read={read} increment={increment} />
  </section>;
  return view;
}

Both buttons share one value and update together. Keep per-user server state inside the request. Signals are an optional low-level API: current direct signal children can miss their initial subscription during mounting, so use explicit owner updates for reliable shared state.

Async work and cleanup

Cancel the previous request when its input changes. Also guard late results: cancellation alone does not stop every Promise. Cancel on disconnect, and show a retryable error.

Search with changing input
import { createAbortSignal, update } from 'radi';

function Search(this: HTMLElement) {
  const lifetime = createAbortSignal(this);
  let request: AbortController | undefined;
  let message = 'Enter a search.';
  lifetime.addEventListener('abort', () => request?.abort(), { once: true });

  async function search(query: string) {
    request?.abort();
    const current = request = new AbortController();
    message = query ? 'Loading…' : 'Enter a search.';
    update(view as HTMLElement);
    if (!query || lifetime.aborted) return;
    try {
      const response = await fetch('/api/search?q=' + encodeURIComponent(query), {
        signal: current.signal,
      });
      if (!response.ok) throw new Error('Request failed');
      const result = await response.text();
      if (current.signal.aborted || current !== request) return;
      message = result || 'No results.';
    } catch {
      if (current.signal.aborted || current !== request) return;
      message = 'Could not load. Submit again to retry.';
    }
    update(view as HTMLElement);
  }

  const view = <form onsubmit={(event: SubmitEvent) => {
    event.preventDefault();
    const form = event.currentTarget as HTMLFormElement;
    void search(String(new FormData(form).get('q') ?? '').trim());
  }}>
    <label>Search <input name="q" oninput={(event: Event) => {
      void search((event.target as HTMLInputElement).value.trim());
    }} /></label>
    <button type="submit">Search</button>
    <p role="status">{() => message}</p>
  </form>;
  return view;
}

Provide your own /api/search endpoint returning text. For live search at scale, debounce input and clear the timer on disconnect. Start DOM-dependent work on connect. Dispose subscriptions, timers, observers and router fetchers when their owner disconnects; remove trees through Radi so cleanup runs.

createAbortSignalOnUpdate cancels on every update, including one used to show loading. Use it only when that is the intended lifetime; use a separate controller for input-dependent requests as above.

Keep updates small

update(view) synchronously visits reactive expressions in that subtree. Radi does not batch your calls or infer dependencies. A small runtime cannot make expensive application work free.

  • Update the smallest ancestor that contains every changed expression. Group related state changes, then call update once.
  • Keep render expressions pure and cheap. Fetch in an action or lifecycle handler; compute costly derived data when its inputs change.
  • Use keyed rows to preserve state, and paginate or virtualize large lists. Keys do not remove the cost of visiting every row.
  • Coalesce pointer/scroll updates with requestAnimationFrame; cancel the frame on disconnect. Debounce network searches separately.
  • Use memo only with a correct invalidation predicate: true skips work, false recomputes. Include every dependency or the view becomes stale.

Before shipping

Build against the installed Radi version. Try typing, reordering, removal, rapid input changes, failed requests and unmounting during a request. Profile a production build with realistic data and a slower device/network; check long tasks, interaction latency, transferred bytes and cleanup after repeated mounts. Measure before adding caches or memoization.

Client routing

Use radi/router for SPA navigation without replacing the document. Keep shared layout outside a route outlet. Routes can load code lazily, read server data through loaders, and mutate it through actions followed by revalidation.

router.ts
import { createElement as h, createRoot } from 'radi';
import { createRouter, fetchRouteData } from 'radi/router';

const root = createRoot(document.getElementById('outlet')!);
const router = createRouter({
  routes: [{
    path: '/pages/:id',
    loader: ({ params, signal }) => fetchRouteData(
      '/api/pages/' + encodeURIComponent(params.id), { signal },
    ),
  }],
});
router.subscribe((state) => {
  if (state.status !== 'idle') return;
  if (state.error) root.render(h('p', null, 'Could not load this page.'));
  else root.render(h('pre', null, JSON.stringify(state.data, null, 2)));
});
router.attach();
await router.ready;
// Dispose when the application is removed: router.dispose();

Serve the application shell for direct visits to these URLs. The example expects a JSON endpoint implemented by your server. Authorization and persistence remain on the server; fresh data can still require a request.

The optional initialData setting reuses data serialized by your server. Rendering an outlet replaces its existing DOM; it does not hydrate it. Routes are flat, with static paths, :parameters, and a final * wildcard. This is not Remix API compatibility.

Caching is disabled by default. Enable staleTime and a bounded cacheSize to reuse prefetched data. Invalidate on account changes. New navigation aborts obsolete loads, but aborting an action cannot undo a server mutation.

Save through a route action

Add to the /pages/:id route
action: ({ params, formData, method, signal }) => fetchRouteData(
  '/api/pages/' + encodeURIComponent(params.id),
  { method, body: formData, signal },
),
Explicit form submission
<form onsubmit={(event: SubmitEvent) => {
  event.preventDefault();
  const form = event.currentTarget as HTMLFormElement;
  void router.submit(location.pathname, new FormData(form), 'POST');
}}>
  <label>Title <input name="title" required /></label>
  <button type="submit">Save</button>
</form>

submit navigates and reloads the destination loader. Show submitting and error states from router.subscribe. Use a component-owned createFetcher for local changes that must preserve the route and its drafts; it runs only the action, so apply its returned data or explicitly reload. Dispose it on disconnect.

For code splitting, add lazy: () => import('./settings') to a route. That module exports loader, action and optionally view. Your subscriber calls root.render(state.module.view(state)) when idle; the router never renders it for you. Provide error/retry UI, focus management and any custom scroll restoration.

For Go integration, serve JSON endpoints beside the HTML handler and share authorized data functions between them. Safely serialize initial data into HTML and pass it as initialData. Authenticate, validate and enforce CSRF protection in the server endpoints; never expose the V8 operation registry as an API.

Server rendering

Use the separate server entry to render HTML without a browser. Choose synchronous rendering, resolved async HTML, or ordered streaming. For JSX on the server, set jsxImportSource to radi/server.

server.ts
import { createElement as h, renderToString } from 'radi/server';

const html = renderToString(h('h1', null, 'Hello from the server.'));
radi/server →

Go + embedded V8

Keep application components in TypeScript/TSX while Go owns HTTP, authorization, and Flop access. The Go adapter embeds V8 in the server process and streams ordered HTML. It uses neither a Node/Deno serving process nor a persisted bytecode cache.

app.tsx
import { data, defineApp } from 'radi/go';

async function Title() {
  const row = await data<{ title: string }>('page', { id: 'home' });
  return <h1>{row.title}</h1>;
}

export const app = defineApp(() => (
  <main><p>Loading your page…</p><Title /></main>
));

Register the page operation in Go and return its Flop result. data() resolves that result as a Promise. Bundle the TSX with radi-build, embed the JavaScript source with go:embed, and pass it to radi.New from github.com/Marcisbee/radi/go. The checked-in example includes the complete Go host and database setup.

Build the Flop example
export CC=clang-21 CXX=clang++-21 CGO_CXXFLAGS=-nostdinc++
go generate ./examples/go-server
go build -trimpath -o radi-go-server ./examples/go-server
RADI_ADDR=127.0.0.1:8080 RADI_DATA_DIR=./data ./radi-go-server

Run these commands from a Radi checkout with Go 1.26.10 and Clang 21. Linux amd64 is the qualified target. The Go-based bundler needs no JavaScript runtime at build time. Each request gets fresh JavaScript state; the default single worker reuses its V8 instance and compiled source.

The adapter bounds queued work, data calls, response bytes, and execution time. Slow readers apply backpressure; disconnects cancel operations. Use trusted application code, external memory limits and a restart policy: the V8 heap setting is not a hard memory sandbox. Go operations must respect cancellation. Pair the adapter with radi/router for client navigation and route data handling through Go HTTP endpoints.

radi/go API · Go host setup

Use your own Go host

Use matching Radi release tags for Go and npm, or one source checkout for both. From the Go application module, bundle the app.tsx above with the following command. Replace VERSION with your pinned release tag; use -radi PATH to resolve a source checkout instead of the installed npm package.

Bundle
go run github.com/Marcisbee/radi/cmd/radi-build@VERSION -entry app.tsx -out app.js

Place this host beside app.js. The page operation returns public demo data; replace it with an authorized, validated database read. Add the matching github.com/Marcisbee/radi module version to go.mod.

main.go
package main

import (
  "context"
  _ "embed"
  "encoding/json"
  "log"
  "net/http"
  "time"

  radi "github.com/Marcisbee/radi/go"
)

//go:embed app.js
var source string

func main() {
  handler, err := radi.New(radi.Config{
    Source: source,
    Operations: map[string]radi.Operation{
      "page": func(ctx context.Context, input json.RawMessage) (any, error) {
        return map[string]string{"title": "Hello from Go"}, ctx.Err()
      },
    },
  })
  if err != nil { log.Print(err); return }
  defer handler.Close(context.Background())
  server := &http.Server{
    Addr: "127.0.0.1:8080", Handler: handler,
    ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second,
  }
  log.Print(server.ListenAndServe())
}

Build with CGO enabled on Linux amd64/glibc using the Clang environment above, then run the binary. V8 has no fetch, timers, Node modules, DOM or Web Streams globals; use data() for I/O. Go operations may run concurrently and must bound their own work and honor context cancellation.

Limits and deployment

Config defaults
radi.Config{
  Source: source,                  // required bundled source
  Workers: 1, Queue: 0,            // excess requests receive 503
  Timeout: 10 * time.Second,       // includes queue time
  WriteTimeout: 5 * time.Second,   // per response write
  MaxHeapBytes: 64 << 20,          // best effort, not an RSS cap
  MaxResponseBytes: 16 << 20,
  MaxDataBytes: 1 << 20,           // each input/result and request metadata
  MaxDataCalls: 32,               // per request
  OnError: func(r *http.Request, err error) { log.Print(err) },
}

Zero selects defaults except Queue, where it means no waiting capacity. Negative limits are rejected. One worker handles one response at a time, including slow readers; more workers cost memory. Data calls also have a global bound of Workers × MaxDataCalls.

The handler accepts GET only. Put mutations and authentication in separate HTTP handlers. Before streaming starts, failures return 500 or 504; after it starts, failure truncates the response. Component failures can render ERROR:ComponentName, so handle expected errors in the application. Keep diagnostics private with OnError.

Serve behind TLS, disable proxy buffering for early chunks, and preserve HTTP flush/write-deadline support in middleware. For shutdown, drain http.Server first, then Close the Radi handler with a deadline and close the database. Monitor errors, latency and process memory. Rebuild the bundle and binary together when upgrading; keep the previous binary for rollback.

Static sites

This guide is built with Radi’s static site generator. Routes become complete HTML files. Your build owns the filesystem writer, asset bundling, and deployment; no Radi server runs in production.

build.ts
import { generateStaticSite } from 'radi/static';
import { createElement as h } from 'radi/server';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';

await generateStaticSite([{
  path: '/',
  title: 'My site',
  render: () => h('h1', null, 'Made with Radi.'),
}], async (path, html) => {
  const file = join('dist', path);
  await mkdir(dirname(file), { recursive: true });
  await writeFile(file, html);
});
radi/static →

Interactive islands

Add explicit resumable boundaries to static HTML. Their handlers update existing DOM without rendering the whole page again. This example includes its handler in the initial script, so the first click needs no additional download.

Live example

A counter on a static page

0

Click to increment the counter.

Server boundary
import { createElement as h, resumable, resumeClick } from 'radi/server';

const counter = resumable({ count: 0 }, [
  h('button', { type: 'button', ...resumeClick('increment') }, 'Add one'),
  h('output', null, '0'),
]);
client.ts
import { resume } from 'radi/resume';
import { increment } from './handler';

resume(document, {
  increment: () => Promise.resolve(increment),
});
handler.ts
import type { ResumeContext } from 'radi/resume';

export function increment({ state, boundary }: ResumeContext) {
  const counter = state as { count: number };
  boundary.querySelector('output')!.textContent = String(++counter.count);
}

Bundle the client entry as a classic script and pass its public URL as the static page’s bootstrap. Dynamic imports are optional: use them for larger, rarely used interactions when the download delay is acceptable.

This is explicit resumption: arbitrary closures are not serialized. Use native links and forms for navigation. The current island API supports registered clicks.

Keep the initial markup and JSON state consistent. State changes persist in memory, not in the HTML attribute. Preserve the boundary’s final template marker: streamed clicks wait for it. Keep ordinary client roots outside islands, since their first render replaces existing HTML. Configure CSP to allow your bootstrap and any lazy handler modules.

Why are islands explicit?

Function children identify dynamic output, but do not identify a browser module or preserve a closure’s captured state. Radi currently has no compiler to extract those functions. A wrapper could generate registry entries for explicit module references, but could not automatically resume arbitrary components.

radi/resume →

Router API

Import from radi/router.

createRouter

Signature
const router = createRouter({ routes, initialData?, staleTime?, cacheSize? });
await router.ready;

Creates one browser router per window. Routes specify path, loader, action, optional lazy module import, and optional view. Loader context contains url, decoded params, and signal. Action context also contains formData and method. initialData skips the first loader; staleTime enables data caching and cacheSize bounds entries (default 20).

state

Signature
router.state;
const unsubscribe = router.subscribe(state => { /* render */ });

State exposes url, match, module, data, actionData, error, and status (idle, loading, or submitting). subscribe immediately calls its listener. Existing data stays visible while busy. Render the outlet or an error when idle; the router never calls a module’s optional view itself.

navigate

Signature
await router.navigate('/pages/about', { replace: false });

Loads a same-origin route, then updates history. Back/forward resolves the browser URL. New navigation aborts obsolete work. Hash-only navigation skips the loader. Unmatched programmatic navigation produces a 404 RouteError.

attach

Signature
const detach = router.attach(document);

Intercepts ordinary clicks on matching internal links. Modified clicks, downloads, external links, explicit targets, and data-radi-reload stay native. Forms are not automatically intercepted.

submit

Signature
await router.submit('/pages/about', formData, 'POST');

Runs the destination action, invalidates cached data, and reloads its loader. Supports POST (default), PUT, PATCH, and DELETE. Action results appear in actionData; a missing action produces 405.

createFetcher

Signature
const fetcher = router.createFetcher<Result>();
fetcher.subscribe(({ data, error, status }) => { /* update a component */ });
await fetcher.submit('/posts/42/reactions', formData, 'POST', { invalidate: ['/posts/42'] });
await fetcher.load('/posts/42/reactions');
fetcher.dispose();

Independent data state without navigation, scrolling, route notifications or automatic loader revalidation. load runs a fresh loader; submit stores action data in fetcher.state.data. Mutations invalidate all cache entries unless an explicit URL list is supplied ([] opts out). Errors and RouteRedirect stay in fetcher state. New work aborts older work on this fetcher; dispose it when its component unmounts. Cancellation cannot undo server writes.

revalidate

Signature
await router.revalidate();
router.invalidate();

revalidate clears cached data and reloads the current browser URL. invalidate() clears all cached data; invalidate([url, ...]) clears selected pathname/search keys without navigating. Use full invalidation after identity changes.

prefetch

Signature
await router.prefetch('/pages/about');

Loads route code and data without navigating. Data reuse requires a positive staleTime. Cache keys include pathname and search; hits do not extend expiry. Catch speculative failures. Prefetch does not follow redirects.

dispose

Signature
router.dispose();

Aborts navigation, fetchers and prefetch, removes installed listeners, and clears data. Dispose before replacing the router.

fetchRouteData

Signature
const data = await fetchRouteData<Page>('/api/page', { signal });

Fetches JSON with Accept: application/json. Accepts JSON or 204; non-success responses throw RouteError with status and response data. A successful X-Radi-Location response redirects. The type parameter describes your contract, not runtime validation.

redirect

Signature
redirect('/pages/home');
throw new RouteRedirect('/pages/home');

Throws a navigation redirect from a loader or action. Only same-origin destinations are supported and chains are limited to ten hops. Ordinary HTTP redirects followed by fetch are not route navigation.

RouteError

Signature
throw new RouteError(404, 'Page not found');

Carries status and data into the router error state. Applications own error rendering, retry UI, focus management, and custom scroll restoration.

Go bridge API

Import from radi/go (embedded server bundle).

defineApp

Example
export const app = defineApp(
  (request: GoRequest): ServerChild => <main>Hello</main>,
);

Defines the application entry consumed by the Go adapter. Export it as app and bundle with radi-build, which selects server JSX and the Radi global. render runs once per request in a fresh V8 context. Components may return promises; content streams in document order. The returned start/pull/settle methods are the host protocol, not browser routing APIs.

data

Signature
data<T = unknown>(name: string, input: unknown = null): Promise<T>

Invokes an operation registered in the Go handler for this request. Input and output cross the bridge as JSON. The type parameter does not validate runtime data. Unknown operations, quota violations, invalid results, operation errors and panics reject the promise. Authorize and validate inside the Go operation; its context carries request cancellation and middleware values. This API is unavailable in the browser.

GoRequest

Signature
interface GoRequest {
  method: string;
  url: string;
  headers: Record<string, string[]>;
}

Request metadata supplied to defineApp. url is the request URI (path and query), not an absolute URL. Header names use Go HTTP canonical casing. The adapter accepts GET; mount separate Go handlers for mutations and other HTTP methods. No request body is exposed.

Client API

Import from radi or radi/client.

createRoot

Signature
const root = createRoot(target: HTMLElement);
root.render(element);
root.root; // current node, or null
root.unmount();

Creates a DOM render root. The first render clears existing children; this is not hydration. Rendering the same component type and key reuses its host and updates props. Unmount disconnects the rendered tree and runs cleanup.

createElement

Example
import { createElement as h, createRoot, Fragment } from 'radi';

const target = document.body.appendChild(document.createElement('div'));
const view = h('main', { className: 'greeting' },
  h(Fragment, null,
    h('h1', null, 'Hello, Radi'),
    h('p', null, 'These siblings share no extra wrapper.'),
  ),
);

createRoot(target).render(view);

Creates DOM elements or component hosts. Components receive a props getter and run once on mount. Function children and attributes are reactive; Fragment groups children without an element wrapper. Use JSX for normal authoring.

update

Signature
update(target: Node): boolean

Synchronously reevaluates reactive expressions on the target and its descendants. Returns the dispatch result. Update the ancestor containing the expressions that read your changed state.

memo

Signature
memo(render: (anchor: Node) => unknown, shouldMemo: () => boolean)

Returns a reactive function. The first evaluation computes its value; later updates retain it when shouldMemo returns true. Return false when dependencies change.

createList

Signature
createList(key => items.map(item =>
  key(() => <Row item={item} />, item.id)
))

Returns a keyed-list descriptor for use inside a reactive child function. Use stable, unique keys. Updates reuse keyed content and reconcile its order; removed items are disconnected.

createKey

Signature
createKey(render: () => unknown, keyValue: unknown)

Returns a single keyed descriptor. Inside a reactive child, the same key preserves the rendered instance; a changed key remounts it.

createAbortSignal

Signature
createAbortSignal(target: Node): AbortSignal

Aborts on the target’s disconnect event. Use it to tie event listeners or async work to the component lifetime.

createAbortSignalOnUpdate

Signature
createAbortSignalOnUpdate(target: Node): AbortSignal

Aborts on the next update or disconnect. Create a new signal for each new unit of work.

Suspense

Signature
<Suspense fallback={() => <p>Loading…</p>}>
  <AsyncComponent />
</Suspense>

Displays its fallback while descendants are suspended. Overlapping suspensions are counted. Async components participate automatically; manual work must balance suspend and unsuspend.

suspend

Signature
suspend(target: Node): boolean
unsuspend(target: Node): boolean

Dispatch bubbling, cancelable suspend/unsuspend events. Return the dispatch result. Call unsuspend when each manual operation finishes, including failure paths.

ComponentNode

Signature
function Component(this: ComponentNode, props: () => Props) {
  // this is the component host
}

Type for the DOM component host. Lifecycle events are connect, disconnect, and update. Props are read by calling their getter, including inside reactive expressions.

Server API

Import from radi/server.

createElement

Signature
createElement(type, props, ...children): ServerNode
createTextNode(text: string): ServerChild
createComment(text: string): ServerNode
Fragment(children: ServerChild[]): ServerNode

Creates lazy server nodes, text, comments, and fragments. These are separate from client DOM and legacy adapter nodes. Components and function children execute during rendering. Text and attributes are escaped; no raw-HTML API is exposed.

renderToString

Signature
renderToString(child: ServerChild): string
renderToStringRoot(child: ServerChild): string
ssr(entry: () => ServerChild): string

Synchronous rendering; promises are rejected. renderToStringRoot is an alias. ssr calls the supplied entry and renders its result. Component errors produce an ERROR:ComponentName fallback; invalid children and reactive errors fail the render.

renderToStringAsync

Signature
renderToStringAsync(child: ServerChild): Promise<string>

Collects fully resolved async output. Components, promises, and siblings are evaluated in document order.

renderToReadableStream

Signature
renderToReadableStream(child: ServerChild): ReadableStream<Uint8Array>

Streams ordered UTF-8 HTML with backpressure. Available HTML flushes before awaiting a promise; later siblings wait. Cancellation stops future traversal, but cannot cancel application promises already running. No out-of-order replacement scripts are emitted.

ServerChild

Signature
type ServerComponent<P> = (props: () => P) => ServerChild;
// ServerNode: lazy server element representation
// ServerChild: nodes, primitives, arrays, functions,
// promises, and subscribable values

ServerChild, ServerComponent, and ServerNode are exported types. Subscribables supply their first synchronous value and are immediately unsubscribed. DOM lifecycle events do not run. Event, key, ref, and function attributes are omitted. Use explicit CSS units.

resumable

Signature
resumable(state: ResumeState, children: ServerChild, tag = 'div'): ServerNode
resumeClick(handler: string): { 'data-radi-click': string }

Creates an explicit island boundary and binds a click to a client registry key. See Resumption API for state, event, and delivery constraints. Legacy createRenderer, createDomAdapter, createServerStringAdapter, and SERVER_RENDERER are also re-exported; use them with adapter nodes only.

Resumption API

Import from radi/resume (client) and radi/server (boundaries).

resume

Signature
const dispose = resume(root: Document | Element, loaders: ResumeLoaders, options?: ResumeOptions);
dispose();

Install once per root before interactive HTML is parsed. Avoid overlapping roots. Returns an idempotent disposer that removes capture, aborts pending delivery, and releases cached loaders. It does not mount or rerender the page.

ResumeLoaders

Signature
const loaders = {
  increment: () => import('./handler').then(m => m.increment),
};
// Or bundle the handler up front:
// increment: () => Promise.resolve(increment)

A record of trusted handler keys to asynchronous loader functions. Loaded handlers are cached; a failed load can retry on a later click. Lazy imports are optional and can delay the first interaction.

ResumeHandler

Signature
type ResumeHandler = (context: ResumeContext) => void | Promise<void>;
// context: boundary, element, event, state, signal

boundary is the island element; element carries the click binding; event is the original MouseEvent (currentTarget is no longer valid). Read or replace state and mutate existing DOM. Honor signal for asynchronous work. Handlers run serially within a boundary. Removed targets are skipped.

ResumeOptions

Signature
resume(document, loaders, {
  maxPending: 64,
  timeout: 30000,
  onError: error => console.error(error),
});

Defaults are 64 queued/running clicks per root and a 30,000 ms wait for handler loading and streamed HTML readiness. Overflow is dropped and reported. The timeout does not forcibly stop application handler code.

ResumeState

Signature
type ResumeState = null | boolean | number | string
  | ResumeState[] | { [key: string]: ResumeState };

Exported by radi/server and radi/resume. State is public HTML: never include secrets. Only finite numbers and JSON values in arrays/plain objects are accepted. Cycles, functions, undefined, BigInt, and class instances are rejected.

resumeClick

Signature
resumeClick('increment')
resumable({ count: 0 }, children, 'section')

Handler keys must be nonempty and at most 256 characters. Supported boundary tags: div, section, article, main, aside, nav, span, li, td. Registered clicks suppress native defaults and propagation, then deliver once to the handler. Use type="button". Navigation, forms, input/change, gestures, and privileged user-activation actions are not replayed.

Static generation API

Import from radi/static.

generateStaticSite

Signature
generateStaticSite(pages: readonly StaticPage[], write: StaticWriter): Promise<string[]>

Validates all routes first, then renders and awaits each write sequentially. Returns generated relative paths. / maps to index.html; /guide/ maps to guide/index.html. Duplicate routes, dot segments, encoded paths, queries, and fragments are rejected. Unicode slugs are supported.

StaticPage

Signature
const page: StaticPage = {
  path: '/guide/',
  title: 'Guide',
  render: () => h('h1', null, 'Guide'),
  lang: 'en',
  head: h('link', { rel: 'stylesheet', href: '/style.css' }),
  bootstrap: '/client.js',
};

path, title, and render are required. render returns ServerChild, including promises. lang defaults to en; head and bootstrap are optional. bootstrap is a public URL for a bundled classic script, placed in the head before interactive content. Without it, no script is generated.

StaticWriter

Signature
type StaticWriter = (path: string, html: string) => void | Promise<void>;

Owns filesystem writes, asset bundling/copying, cleanup, and deployment. Build into a fresh output directory: failures do not roll back earlier writes, and removed routes are not deleted. Deploy directory indexes; use the correct asset URL prefix for subdirectories.

Signals and channels

Import from radi/signal and radi/channel respectively.

createSignal

Signature
const count = createSignal(0);
count(); // read
count(1); // write
const label = count(value => `Count: ${value}`);

Creates a callable signal. Writes notify connected subscriber nodes through update. A mapper call creates a reactive child function; it is not a functional setter. Subscribers attach through Radi connect/disconnect events, but direct signal children can miss the initial connect during mounting; prefer explicit owner updates for shared state. The current overload types do not fully express the mapper’s returned function.

createChannel

Signature
const theme = createChannel('light');
const provider = theme.provide(element, 'dark');
const value = theme.use(descendant);
value();
provider.set(previous => previous === 'dark' ? 'light' : 'dark');

Creates a DOM-scoped channel with a default value and unique key. provide attaches a value to an element; use resolves the nearest live provider after connection. Before resolution it reads the default, and writes are ignored.

ChannelAccessor

Signature
value();
value.set(nextValueOrUpdater);
value.update();
value.provider;
value.resolved;

Accessors read the current value. set changes it and updates the provider subtree; update forces a refresh after in-place mutation. provider is an Element or null, and resolved indicates provider discovery. Channel accessor types are inferred, not separately exported.

Custom renderer API

Import from radi/renderer.

createRenderer

Signature
const renderer = createRenderer(adapter: RendererAdapter);
renderer.createElement(type, props, ...children);
renderer.createTextNode(text);
renderer.createComment(text);
renderer.fragment(children);
renderer.render(root, child);
renderer.renderToString?.(child);

Legacy universal renderer for custom targets. render appends children to a UniversalNode root. renderToString exists only if the adapter supplies serialize. This is a one-shot abstraction, not full client reactivity or the lazy server streaming API.

adapters

Signature
createDomAdapter(document?)
createServerStringAdapter()
DOM_RENDERER
SERVER_RENDERER

Adapter factories target DOM or virtual string nodes. DOM_RENDERER and SERVER_RENDERER are prebuilt renderer instances. Keep creation and rendering within the same API; client DOM nodes, lazy server nodes, and UniversalNode wrappers are not interchangeable.

RendererAdapter

Signature
interface RendererAdapter {
  createElement(tag); createTextNode(text); createComment(text);
  setProperty(node, name, value);
  insertNode(parent, node, anchor?); removeNode(parent, node);
  getParentNode(node); getFirstChild(node); getNextSibling(node);
  isTextNode(node); isCommentNode?(node); serialize?(root);
}

Implement node creation, mutation, navigation, and text detection. isCommentNode and serialize are optional. Exported types include RendererAdapter, Renderer, UniversalNode, and ComponentType. UniversalNode wraps a platform node with optional children, text, comment, tag, props, and key metadata.

JSX and shared types

Import from radi/jsx-runtime, radi/jsx-dev-runtime, radi/server/jsx-runtime, radi/server/jsx-dev-runtime, radi/types, radi/symbols.

JSX

Example
import { createRoot } from 'radi';
import { jsx, jsxs, Fragment } from 'radi/jsx-runtime';
import { jsxDEV } from 'radi/jsx-dev-runtime';

const target = document.body.appendChild(document.createElement('div'));
const view = jsxs('main', {
  children: [
    jsx('h1', { children: 'Hello, Radi' }),
    jsxs(Fragment, {
      children: [
        jsx('p', { children: 'First sibling' }),
        jsx('p', { children: 'Second sibling' }),
      ],
    }),
    jsxDEV('p', { children: 'Development output' },
      undefined, false, { fileName: 'main.ts', lineNumber: 16 }),
  ],
});

createRoot(target).render(view);

Compiler-facing JSX factories. Set jsxImportSource to radi for DOM or radi/server for server nodes. Development entries re-export the runtime; jsxDEV accepts source metadata. The example shows calls normally generated by the JSX compiler: children live in props; jsx takes one child and jsxs takes multiple. The optional third argument is the key. jsxDEV also takes the static-children flag and optional source/self metadata. Server JSX exposes its own JSX namespace, including Element, ElementType, LibraryManagedAttributes, ElementChildrenAttribute, and IntrinsicElements.

shared-types

Signature
isSubscribable(value): value is Subscribable<unknown>
isReactiveGenerator(value): value is ReactiveGenerator
toChildArray(child): Child[]

radi/types exports Child, ReactiveGenerator, and Subscribable<T>. Subscribable.subscribe(listener) may return void, a cleanup function, or an unsubscribe object. The guards test for a subscribe method or a function respectively. toChildArray wraps a single child; it does not flatten nested arrays.

symbols

Signature
COMPONENT, REACTIVE_CHILDREN, TAIL, RENDER_ID, RENDER, MEMO,
REACTIVE_ATTRIBUTES, TYPE, PROPS, INSTANCE, UPDATE_ID, CLEANUP,
SINGLE_KEYED, KEY, NODE, KEY_MAP, ATTRS, KEYED

radi/symbols exposes runtime bookkeeping symbols for low-level integrations. Ordinary application code should use the public rendering and state APIs rather than mutate these fields.