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, counting those inside nested custom elements, 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 the Empty Values And Removal section of JSX Overview 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 ? undefined : signIn}
		title={state.username === '' ? 'Enter your username first' : undefined}
	>
		{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={... ? '...' : undefined} — undefined removes the attribute once a username exists
  • on:click={... ? undefined : signIn} — undefined detaches the handler while a sign-in request is in flight
  • child text swaps between the two states as normal content

The template shape changes between these renders: title and on:click are present in the first template but are dropped from the second because their values are undefined. The renderer therefore replaces the committed button node. A child expression is different: its child part keeps its place whatever value it holds, so changing child content does not change the surrounding element's template shape.

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.