Components and JSX

Components and JSX

A component is a function that runs once and returns DOM nodes. The parts that change are the functions you place in JSX.

JSX creates real nodes

tsx
const button = <button class="btn">Save</button>;
button instanceof HTMLButtonElement; // true

There is no virtual DOM and no diffing. When render() runs your component, the elements are created once; signals then update the specific attributes and text nodes that read them.

The three rules

1. Read signals inside functions

tsx
function Greeting() {
  const [name, setName] = createSignal('Ada');

  return (
    <>
      <p>Hello, {name}</p>                      {/* updates */}
      <p>Hello, {() => name().toUpperCase()}</p> {/* updates */}
      <p>Hello, {name()}</p>                    {/* read once: never updates */}
      <input value={name()} onInput={(e) => setName(e.currentTarget.value)} />
    </>
  );
}

Attributes work the same way: class={() => (active() ? 'on' : '')} is reactive, class={active() ? 'on' : ''} is computed once.

2. Props arrive as they are

Props are passed through untouched. A function stays a function, so callbacks and render props work:

tsx
function Price(props: { amount: number; format: (n: number) => string }) {
  return <span>{props.format(props.amount)}</span>;
}

<Price amount={42} format={(n) => `$${n.toFixed(2)}`} />;

To accept either a value or a signal, type the prop as MaybeAccessor<T> and read it with access():

tsx
import { access, type MaybeAccessor } from '@velozjs/dom';

function Badge(props: { count: MaybeAccessor<number> }) {
  return <span class="badge">{() => access(props.count)}</span>;
}

<Badge count={3} />
<Badge count={unread} /> {/* unread is a signal getter */}

3. Pass a function to render lazily

JSX evaluates children before the parent component runs. That's fine most of the time, but when content should be created later or inside a component's scope, pass a function:

tsx
<Show when={open}>{() => <ExpensivePanel />}</Show>  {/* created only when shown */}

<ErrorBoundary fallback={<p>Something broke</p>}>
  {() => <Widget />}                                {/* errors are caught */}
</ErrorBoundary>

<Theme.Provider value="dark">{() => <App />}</Theme.Provider> {/* sees the context */}

Events

Handlers are functions in on* props. Bubbling events (click, input, keydown...) are delegated; others (focus, blur, mouseenter, scroll...) get a listener on the element. Either way, event.currentTarget is the element with the handler.

tsx
<button onClick={(e) => console.log(e.currentTarget)}>Click</button>
<input onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} />

Refs

tsx
let input!: HTMLInputElement;
<input ref={(el) => (input = el)} />;

const box = { current: null as HTMLDivElement | null };
<div ref={box} />;

Styles and classes

tsx
<div
  class={() => ['card', selected() && 'card-selected']}
  classList={() => ({ disabled: locked() })}
  style={() => ({ opacity: visible() ? 1 : 0.4, '--progress': `${pct()}%` })}
/>

Style objects accept camelCase and custom properties, numbers get px (except unitless properties like opacity), and keys that disappear on the next update are removed.

Forms

value, checked and selected are applied after the other attributes and the children, so <select value="b"> selects an option rendered inside it, and an <input type="range"> value isn't clamped by the order you write min/max.

tsx
<select value={() => size()} onChange={(e) => setSize(e.currentTarget.value)}>
  <For each={sizes}>{(s) => <option value={s}>{s}</option>}</For>
</select>

Types

Each element knows its DOM type: ref on a <dialog> receives an HTMLDialogElement, and handlers get a typed currentTarget, so e.currentTarget.value needs no cast. Render-function children (<Show>{(user) => …}</Show>) are typed too. This needs TypeScript 5.1+.

SVG

SVG tags (svg, path, circle, g, use...) are created in the SVG namespace automatically, so inline icons just work.

tsx
<svg viewBox="0 0 24 24" class="icon"><path d="M5 12h14" /></svg>

Rendering

tsx
import { render } from '@velozjs/dom';

const dispose = render(() => <App />, document.getElementById('app')!);
dispose(); // removes the content and stops every effect

Without JSX

ts
import { createElement as h, render } from '@velozjs/dom';

render(() => h('button', { onClick: () => alert('hi') }, 'Hello'), document.body);