Morphos & Kosmesis are here.Explore
PraxisJS

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/jsx
pnpm add @praxisjs/jsx
yarn add @praxisjs/jsx
bun add @praxisjs/jsx

The 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>
  )
}
Storybook
Live demo — reactive vs. static

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.

Storybook
Live demo — lists

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.

Storybook
Live demo — fine-grained rows

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.

Storybook
Live demo — event handlers

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 for true and removed for false.
  • 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) for true and removed for false.
<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?

On this page