Radiant0.3.0-rc.10

JSX Client Rendering

This page is about the client entrypoints. Import from @ecopages/jsx or @ecopages/jsx/client; keep @ecopages/jsx/server in server-only modules.

  • mount new DOM with createRoot(...).render(...)
  • hydrate existing SSR markup with hydrate(...) or createRoot(...).hydrate(...)

For server HTML generation, see JSX SSR.

Direct DOM Rendering

Use createRoot(...) when you want to mount JSX directly outside a Radiant custom-element host.

/** @jsxImportSource @ecopages/jsx */
 
import { createRoot } from '@ecopages/jsx';
 
function App() {
	return <p>Hello JSX</p>;
}
 
const container = document.querySelector('#app');
 
if (container instanceof HTMLElement) {
	createRoot(container).render(<App />);
}

The root API is intentionally small:

  • render(element) mounts or updates the target
  • hydrate(element) attaches bindings onto matching SSR output
  • unmount() disposes the mounted tree and clears the target

Hydrating Existing Markup

Hydration is the client attach step for markup that was produced with renderToString(..., { mode: 'hydrate' }).

/** @jsxImportSource @ecopages/jsx */
 
import { createRoot } from '@ecopages/jsx';
 
function App() {
	return <p>Hello JSX</p>;
}
 
const container = document.querySelector('#app');
 
if (container instanceof HTMLElement) {
	createRoot(container).hydrate(<App />);
}

You can also call hydrate(element, target) directly.

/** @jsxImportSource @ecopages/jsx */
 
import { hydrate } from '@ecopages/jsx';
 
function App() {
	return <p>Hello JSX</p>;
}
 
const container = document.querySelector('#app');
 
if (container instanceof HTMLElement) {
	hydrate(<App />, container);
}

If the target does not contain hydration markers, the runtime falls back to a normal client render.

For render-owning RadiantElement custom-element hosts, there is one extra gate: the explicit Radiant hydrator must also be installed. See Hydration.

Checking SSR Output

Use hasHydrationMarkers(...) when you need to detect whether a target contains SSR binding markers before attempting client attach behavior.

/** @jsxImportSource @ecopages/jsx */
 
import { createRoot, hasHydrationMarkers } from '@ecopages/jsx';
 
function App() {
	return <p>Hello JSX</p>;
}
 
const container = document.querySelector('#app');
 
if (container instanceof HTMLElement && hasHydrationMarkers(container)) {
	createRoot(container).hydrate(<App />);
}

Fine-Grained Child Updates

Child values can update without rerendering the parent tree when they expose get() and subscribe(...) or when you wrap an external source with createSubscribableJsxValue(...).

That makes this package useful both as a plain JSX runtime and as a renderer that can consume signal-like values directly.

/** @jsxImportSource @ecopages/jsx */
 
import { createRoot, createSubscribableJsxValue } from '@ecopages/jsx';
 
let count = 0;
const subscribers = new Set<(value: number) => void>();
 
const boundCount = createSubscribableJsxValue({
	getValue: () => count,
	subscribe: (notify) => {
		subscribers.add(notify);
 
		return () => {
			subscribers.delete(notify);
		};
	},
});
 
const root = createRoot(document.querySelector('#app') as HTMLElement);
root.render(<p>Count: {boundCount}</p>);
 
count += 1;
 
for (const subscriber of subscribers) {
	subscriber(count);
}

Pass signal-like children directly when they already expose get() and subscribe(...). Use createSubscribableJsxValue(...) when the source has its own update notifications but does not match that shape.

Empty Values During Updates

See JSX Overview — Empty Values And Removal for the full rules. The sign-in button below applies them across two client updates:

/** @jsxImportSource @ecopages/jsx */
 
import { createRoot } from '@ecopages/jsx';
 
const root = createRoot(document.querySelector('#app') as HTMLElement);
 
type SignInState = {
	username: string;
	busy: boolean;
};
 
const signIn = () => console.log('sign in');
 
const renderSignInButton = (state: SignInState) => (
	<button
		type="button"
		disabled={state.busy || state.username === ''}
		on:click={state.busy ? null : signIn}
		title={state.username === '' ? 'Enter your username first' : null}
	>
		{state.busy ? 'Signing in...' : 'Sign in'}
	</button>
);
 
root.render(renderSignInButton({ username: '', busy: false }));
root.render(renderSignInButton({ username: 'ada', busy: true }));

How each removal rule applies between those two renders:

  • disabled={...} — evaluates to true while the username is empty or a request is in flight; when it evaluates to false, the boolean attribute is removed
  • title={... ? '...' : null}null removes the attribute once a username exists
  • on:click={... ? null : signIn}null detaches the handler while a sign-in request is in flight
  • child text swaps between the two states as normal content

The template shape is the same in both renders, so the renderer preserves the committed button node and only patches bindings. That is the general rule: if a later render changes the template shape, the client renderer may replace the affected node instead of preserving the previous instance.

Runtime Output Contract

jsx() and jsxs() return a template result object with:

  • static string segments
  • dynamic values
  • a stable marker used by the Radiant renderers

The distinction between jsx() and jsxs() is important:

  • jsx() is emitted when the source has one logical child value
  • jsxs() is emitted when the source has multiple sibling children

Radiant uses that distinction to preserve child-slot structure from the automatic JSX transform.