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(...)orcreateRoot(...).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 targethydrate(element)attaches bindings onto matching SSR outputunmount()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 totruewhile the username is empty or a request is in flight; when it evaluates tofalse, the boolean attribute is removedtitle={... ? '...' : null}—nullremoves the attribute once a username existson:click={... ? null : signIn}—nulldetaches 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 valuejsxs()is emitted when the source has multiple sibling children
Radiant uses that distinction to preserve child-slot structure from the automatic JSX transform.