JSX Syntax
PraxisJS uses a custom JSX runtime. Learn how to write reactive templates, handle events, use fragments, and map lists.
JSX Syntax
PraxisJS uses a custom JSX runtime (@praxisjs/jsx). Files with JSX must use the .tsx extension.
npm install @praxisjs/jsxpnpm add @praxisjs/jsxyarn add @praxisjs/jsxbun add @praxisjs/jsxThe one rule
Arrow functions are reactive, plain expressions are static.
render() {
return (
<div>
{() => this.name} {/* ✅ reactive — updates on signal change */}
{this.name} {/* ❌ static — read once at render time */}
{() => this.count * 2} {/* ✅ reactive expression */}
{() => this.active ? 'on' : 'off'} {/* ✅ reactive conditional string */}
</div>
)
}Conditional rendering
Use an arrow function so the condition re-evaluates when the signal changes:
render() {
return (
<div>
{/* Renders or removes <Modal /> when isOpen changes */}
{() => this.isOpen && <Modal />}
{/* Ternary — swaps between two components */}
{() => this.loading ? <Spinner /> : <Content />}
</div>
)
}Lists
Render arrays with For from @praxisjs/runtime. It keeps one row per key: an update renders only the new items, moves the existing rows into the new order and removes the ones that left.
import { For } from '@praxisjs/runtime'
render() {
return (
<ul>
<For each={() => this.items} by={(item) => item.id}>
{(item) => <li>{() => item().name}</li>}
</For>
</ul>
)
}The row function receives accessors: item() is the current item for that key and index() its position. Both update the bindings that read them when the item or the position changes, without recreating the row. Index does the same by position, for primitives and lists edited in place.
Plain .map() with key
{() => this.items.map((item) => <li key={item.id}>…</li>)} still works. @praxisjs/vite-plugin rewrites this pattern into a For at build time, so rows are kept here too, and warns about keyed .map() calls it can't rewrite. Without a key, or without the plugin, the whole list is rebuilt each time the arrow re-runs. See .map() with key.
Fine-grained rows
For tracks only each: the row function runs once per key and is not re-run when signals it read change. Keep each structural (read only what decides which rows exist and in which order) and read each row's content in nested arrows:
@State() todos: Todo[] = []
render() {
return (
<ul>
<For each={() => this.todos} by={(todo) => todo.id}>
{(todo) => (
<li class={() => (todo().done ? 'done' : '')}>
<input
value={() => todo().title}
onInput={(e) => this.rename(todo().id, (e.target as HTMLInputElement).value)}
/>
</li>
)}
</For>
</ul>
)
}
rename(id: number, title: string) {
this.todos = this.todos.map((t) => (t.id === id ? { ...t, title } : t))
}Editing a title replaces one object in todos. For sees the same keys in the same order, does no DOM work, and the bindings of that one row read the new object: the <li> and the <input> are kept, so focus, caret position and scroll survive typing.
For lists whose rows change often, give each row its own state. A row that is a store (or a class instance) with @State() fields needs no by at all, since the object itself is the key, and a change to one row's field re-runs only that row's bindings:
import { ReactiveStore } from '@praxisjs/store'
class TodoItem extends ReactiveStore {
@State() title = ''
@State() done = false
}
<For each={() => this.todos}>
{(todo) => <li class={() => (todo().done ? 'done' : '')}>{() => todo().title}</li>}
</For>When rows carry richer local state, make each row a component that owns it with @State() and receives its item as a prop. For creates the component once per key and keeps it, so that state survives reorders.
Event handlers
Event props use camelCase (onClick, onInput, onKeyDown, onPointermove, etc.). Event handlers are plain arrow functions — they don't need to be reactive. Each handler runs inside a batch, so several state writes in the same handler update the DOM once, after the handler returns:
render() {
return (
<div>
<button onClick={() => this.count++}>Increment</button>
<input
onInput={(e) => {
this.value = (e.target as HTMLInputElement).value
}}
/>
</div>
)
}Only the synchronous part of a handler is batched. After an await, writes flush one by one — wrap them in batch() from @praxisjs/core/internal if you need them coalesced.
CSS classes
Static class:
<div class="card elevated">...</div>Reactive class (rebuilds when the signal changes):
<div class={() => this.active ? 'card active' : 'card'}>...</div>Inline styles
Static:
<div style={{ color: 'red', fontSize: '16px' }}>...</div>Reactive:
<div style={() => ({ opacity: this.visible ? 1 : 0, transform: `scale(${this.scale})` })}>
...
</div>Attributes
Strings and numbers are written as they are, and null or undefined removes the attribute. Booleans follow what the attribute means:
- HTML boolean attributes (
disabled,hidden,required…) are present fortrueand removed forfalse. - ARIA states (
aria-selected,aria-expanded,aria-checked…) are written as the strings"true"and"false", which is what assistive technology reads.aria-selected={this.isActive}needs no conversion. - Other attributes such as
data-*are present (empty) fortrueand removed forfalse.
<li role="option" aria-selected={() => this.isSelected(item.id)} data-active={() => this.active === item.id}>
{item.label}
</li>Fragments
Render multiple elements without a wrapper node:
render() {
return (
<>
<Header />
<Main />
<Footer />
</>
)
}Fragment children follow the same rule as element children: an arrow function is a reactive region, a plain expression is static.
render() {
return (
<>
<dt>{this.term}</dt>
{() => (this.expanded ? <dd>{this.definition}</dd> : null)}
</>
)
}ref — accessing DOM elements
Use @Ref<T>() from @praxisjs/decorators to declare a typed DOM ref. The decorated field is both the ref callback and the holder of the element:
import { Component, Ref } from '@praxisjs/decorators'
@Component()
class InputFocus extends StatefulComponent {
@Ref<HTMLInputElement>()
inputRef!: Ref<HTMLInputElement>
onMount() {
this.inputRef.current?.focus()
}
render() {
return <input ref={this.inputRef} />
}
}Ref<T> is callable (used as the ref prop) and holds the element in .current after mount. The callback fires once when the element is inserted into the DOM — it is not called again on unmount.
For refs outside of a class — module-level constants or options like root in @Lazy — use createRef<T>():
import { createRef } from '@praxisjs/decorators'
const scrollRoot = createRef<HTMLDivElement>()
// pass to @Lazy({ root: scrollRoot }) or use in JSX:
<div ref={scrollRoot}>...</div>You can also store the element directly with a plain callback when you don't need a ref object:
render() {
return <input ref={(el) => { this.inputEl = el }} />
}ref — accessing component instances
The same ref prop works on component tags. The callback receives the component instance after onMount completes, and null after the component unmounts.
@Component()
class Parent extends StatefulComponent {
private modal: Modal | null = null
render() {
return (
<>
<button onClick={() => this.modal?.open()}>Open</button>
<Modal ref={(inst) => { this.modal = inst }} />
</>
)
}
}Use ComponentRef<C> from @praxisjs/jsx to type the field explicitly:
import type { ComponentRef } from '@praxisjs/jsx'
@Component()
class Parent extends StatefulComponent {
private modal: ComponentRef<typeof Modal> = null // (instance: Modal | null) => void ... typed field
// or just store the instance:
private modalInstance: Modal | null = null
render() {
return <Modal ref={(inst) => { this.modalInstance = inst }} />
}
}ComponentRef<C> resolves to (instance: InstanceType<C> | null) => void, so the callback argument is fully typed to the component class.
Timing
The ref callback fires in a microtask after onMount has run on the child component. If you need to call a method on the instance immediately after mount, do it inside onMount of the parent — the child will already be mounted by then.
ref is not forwarded
The ref prop is stripped by the runtime before the component receives its props. The child component never sees it — it is handled entirely by the parent's rendering scope.
What's next?
- Lifecycle Hooks — when
onMountfires and how to clean up - Reactivity & Signals — the full signal model, computed values, and reading without tracking
- Decorators: Events & Slots —
@Emit,@Slot, and@OnCommand