Skip to main content

JSX in Exodra

Exodra JSX is not React JSX. Props are split into typed buckets, and reactive values are passed as bindable objects — not thunks and not .value reads. This explicitness is what lets the renderer wire up reactivity once, with no diffing.

Setup​

Babel configuration​

JSX requires the Exodra Babel plugin. TypeScript's native JSX transform and @babel/plugin-transform-react-jsx are incompatible — do not use them.

npm install --save-dev @exodra/babel-plugin-jsx

Configure .babelrc:

{
"plugins": [
["@exodra/babel-plugin-jsx", { "hoistStatic": true }]
]
}

Plugin options: importSource (default @exodra/core), pragma (default h), pragmaFrag (default Fragment), and hoistStatic. There is no optimize or staticHoisting option.

TypeScript configuration​

{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "@exodra/jsx"
}
}

The typed buckets​

Every JSX prop goes into one of these buckets. Flat React-style attributes (<div id="x" class="y" onClick={fn}>) are a compile error.

BucketHoldsExample
static={{ }}Values that never changestatic={{ id: 'app', class: 'box' }}
bindable={{ }}Reactive values (bindable objects)bindable={{ textContent: title }}
bindableList={{ }}Reactive lists (list() objects)bindableList={{ children: rows }}
handlers={{ }}Event handlershandlers={{ onClick: fn }}
bindableHandlers={{ }}Reactive handlersbindableHandlers={{ onClick: handlerBindable }}

Directives are also available: bind:value, bind:checked, cache:key (alias cacheKey), and exo:schema.

Basic elements​

import { bindable } from '@exodra/reactivity';

const message = bindable('Hello World');

// Static content
<div static={{ id: 'container', class: 'wrapper' }}>
Hello World
</div>

// Reactive text — pass the bindable directly, never a thunk or .value
<div bindable={{ textContent: message }} />

Reactive values are objects, not thunks​

The bindable bucket takes bindable/derived objects, passed by reference. Never write () => x.value — there is no .value and no thunk form.

import { bindable, derive } from '@exodra/reactivity';

const isVisible = bindable(true);
const hidden = derive(isVisible, v => !v);
const cls = derive(isVisible, v => (v ? 'panel panel--open' : 'panel'));

<div
static={{ id: 'panel' }}
bindable={{ class: cls, hidden }}
/>

To update state, call setValue:

<button handlers={{ onClick: () => isVisible.setValue(!isVisible.getValue()) }}>
Toggle
</button>

Components​

A component is a function whose props arrive through the buckets. Read them via the context (getConstant etc.) or, in JSX-authored components, off the destructured attributes — here we show the schema-returning style used across the examples.

import { defineComponent, h, text } from '@exodra/core';

const Button = defineComponent(context => {
const label = context.getConstant('label') ?? 'Click';
const onClick = context.getConstant('onClick');
return h('button', {
static: { class: 'btn', children: text(label) },
handlers: { onClick },
});
});

// Usage — props go in static, handlers in handlers
<Button static={{ label: 'Click me' }} handlers={{ onClick: handleClick }} />

Children are just static.children (or a bindable/bindableList for reactive children):

const Card = defineComponent(context =>
h('div', { static: { class: 'card', children: context.getConstant('children') } })
);

<Card static={{ children: 'Card content' }} />

Event handling​

Handlers always live in the handlers bucket. The handler receives the DOM event.

const inputValue = bindable('');

<input
static={{ type: 'text', placeholder: 'Enter text' }}
bindable={{ value: inputValue }}
handlers={{
onInput: e => inputValue.setValue((e.target as HTMLInputElement).value),
}}
/>

<form
handlers={{
onSubmit: e => {
e.preventDefault();
submit();
},
}}
/>

List rendering​

For dynamic lists, use a list() from @exodra/reactivity in the bindableList bucket. The list reconciles by operations, so no React-style keys are needed.

import { h, text } from '@exodra/core';
import { list } from '@exodra/reactivity';

const rows = list([
h('li', { static: { children: text('First') } }),
h('li', { static: { children: text('Second') } }),
]);

<ul bindableList={{ children: rows }} />

Mutate the list to update the DOM:

rows.push(h('li', { static: { children: text('Third') } }));
rows.remove(0);

A list() has no map/filter/forEach. To render from a plain array, build the children array eagerly and put it in static.children:

const items = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }];

<ul
static={{
children: items.map(item =>
h('li', { static: { children: text(item.name) } })
),
}}
/>

Conditional rendering​

Render different reactive children by deriving a child schema from a bindable and binding it as the single reactive child:

import { derive } from '@exodra/reactivity';

const view = derive(isLoggedIn, ok => (ok ? <Dashboard /> : <Login />));

<div bindable={{ children: view }} />

Fragments​

<>…</> compiles to Fragment (a Symbol from @exodra/jsx). Use a fragment to group static and dynamic children together:

<>
<h1 static={{ class: 'title' }}>Title</h1>
<ul bindableList={{ children: rows }} />
</>

Static template caching​

The Babel plugin can hoist and clone static DOM templates in loops when hoistStatic is enabled. For repeated static templates you can also add a cache key explicitly with the cache:key directive (alias cacheKey):

items.map(item => (
<div static={{ class: 'item-wrapper' }} cache:key="item-template">
<span static={{ class: 'badge' }}>Static template content</span>
</div>
))

Components are never auto-cached (they may hold state); each <MyComponent /> gets its own instance.

Prefer static for non-reactive values​

// Good — static attributes for values that never change
<div static={{ id: 'container', class: 'wrapper', 'data-testid': 'main' }} />

// Avoid — wrapping constant values in reactivity adds needless overhead
<div bindable={{ class: derive(noop, () => 'wrapper') }} />