Components

TSRX introduces the component keyword. Components are declared like functions but use component instead of function. Props are typed with a single parameter, and templates live directly in the component body — alongside logic, event handlers, and scoped styles.

 1 export component Button({ label, onClick }: {
 2   label: string;
 3   onClick: () => void;
 4 }) {
 5   <button class="btn" {onClick}>
 6     {label}
 7   </button>
 8
 9   <style>
10     .btn {
11       padding: 0.5rem 1rem;
12       border-radius: 4px;
13     }
14   </style>
15 }

Components are exported with export component the same way you'd export a function. The compiler transforms them into standard framework components for whichever target you're using.

Since JSX in TSRX is statement-based (see Statement-based JSX), anything that needs richer control flow or imperative logic can simply live in a plain inline function. Declare a const fn = () => { ... } next to your template, write whatever JS/TS you like inside it, and call it from an event handler or expression — it stays ordinary JavaScript, unaffected by the component body's template rules.

 1 export component Counter() {
 2   let count = 0;
 3
 4   // Bail out of template rules with an inline function —
 5   // plain JS/TS control flow, no JSX-statement restrictions.
 6   const increment = () => {
 7     if (count >= 10) {
 8       count = 0;
 9     } else {
10       count += 1;
11     }
12   };
13
14   <button onClick={increment}>
15     "Count: "{count}
16   </button>
17 }

Statement-based JSX

In TSRX, JSX elements are statements — they appear directly in the component body rather than being expressions you assign or return. Static text can be written as direct double-quoted children, while dynamic values and JavaScript expressions still use {}.

1 component Greeting() {
2   <h1>"Hello World"</h1>
3   <p>"Welcome to TSRX."</p>
4 }

This means you can't assign JSX to a variable or return it from a function by default. When you need native TSRX as a value — to assign it, pass it as a prop, or return it from a helper — wrap it in <tsrx>...</tsrx>. Use <>...</> or <tsx>...</tsx> when you want JSX-style children instead:

1 component App() {
2   const title = <tsrx><span class="title">"Settings"</span></tsrx>;
3   <Card {title} />
4 }

Text expressions

Because JSX is statement-based, unquoted bare text can't sit directly inside an element the way it does in React. Static text may be written as a direct double-quoted child. JavaScript string literals, template literals, variables, function calls, ternaries, and other dynamic values live inside expression containers { ... }.

1 component Inbox({ name, count }: { name: string; count: number }) {
2   <p>"Hello, "{name}"!"</p>
3   <p>{`You have ${count} unread messages`}</p>
4   <p>{count > 0 ? 'Check your inbox.' : 'All caught up.'}</p>
5 }

Adjacent { ... } containers concatenate with adjacent double-quoted text when rendered, so you can mix static strings with dynamic values without extra wrapping elements. Expressions are re-evaluated whenever their dependencies change, so dynamic text updates automatically on reactive targets like Ripple and Solid.

A bare { expr } is polymorphic — the compiler picks the right output based on where it sits and what the expression evaluates to. For the rare case where you need to force text handling, TSRX reserves a text keyword that can appear as the first token inside a container:

{text expr} — forces the value to render as an escaped text node. The expression is coerced to a string and any HTML-special characters (<, >, &) are escaped. Use this when the surrounding context could otherwise be ambiguous — for example when the expression might resolve to a JSX fragment, or when you want to guarantee text handling for untrusted input.

1 component Price({ amount }: { amount: number }) {
2   // {text ...} forces text-node rendering — the value is stringified
3   // and escaped, even if it happens to contain < or & characters.
4   <span>{text amount}</span>
5 }

Omitting text is almost always what you want; reach for it only when you specifically need the escaping guarantee.

Raw HTML

The {html expr} directive is an escape hatch for inserting pre-rendered markup directly into the DOM, similar in spirit to Svelte's {@html ...}. Use it as child content: <article>{html source}</article>.

The html expression {html ...} must be the sole child of a host element. Ripple also supports child-level raw HTML mixed with sibling children.

1 component Article({ markup }: { markup: string }) {
2   // {html ...} inserts trusted markup as raw HTML.
3   <article>{html markup}</article>
4 }
FrameworkChildren use
<div>{html markup}</div>
Sibling childrenMaps to
React✓ sole childNodangerouslySetInnerHTML
Preact✓ sole childNodangerouslySetInnerHTML
Solid✓ sole childNoinnerHTML
Vue✓ sole childNoinnerHTML
Ripple✓ with siblingsYesnative

Security: only use {html ...} with content you trust. Any string that originated from user input must be sanitised first — otherwise you're opening an XSS vector. If in doubt, stick with the default { ... } container or {text ...}.

Lazy destructuring

TSRX adds two sigils — &{ ... } for objects and &[ ... ] for arrays — that look like destructuring but defer the actual property access until each binding is read. Every reference to a lazy binding is compiled back to a property lookup on the source object, so downstream readers pick up the latest value without a manual getter.

The most common use is in component parameters. Regular object destructuring would snapshot each prop at call time and break any target runtime that relies on per-access reactivity (Ripple, Solid). The most common use is in component parameters. Regular object destructuring would snapshot each prop at call time and break any target runtime that relies on per-access reactivity (Ripple, Solid, Vue). &{ ... } preserves that reactivity while keeping the ergonomic destructuring syntax:

1 component UserCard(&{ name, age }: { name: string; age: number }) {
2   <div>
3     <h2>{name}</h2>
4     <p>"Age: "{age}</p>
5   </div>
6 }

Lazy destructuring is supported across every target. In React and Preact it compiles to direct property access on the source object; in Ripple, Solid, and Vue it preserves tracked, signal, or proxy-backed reactivity without needing a wrapper call.

Prop shorthands

When a prop name matches the variable you're passing, you can use the shorthand {name} instead of name={name}. This works for any attribute or prop — including event handlers like {onClick}.

1 // Instead of repeating the name:
2 <Input value={value} onChange={onChange} />
3
4 // Use the shorthand:
5 <Input {value} {onChange} />

DOM refs

Use refs when you need the DOM node. There are three forms:

SyntaxUse it for
{ref value}A direct ref on the current element.
ref={value}The native-style ref attribute.
inputRef={ref value}A named ref prop that can be forwarded through components.

The {ref expr} and ref={expr} forms attach a ref directly to the current element.

Named ref props anyName={ref expr} are useful for component APIs, and they also work on host elements: TSRX applies them as refs instead of emitting them as attributes.

 1 component AutoFocus() {
 2   function focusInput(node: HTMLInputElement | null) {
 3     node?.focus();
 4   }
 5
 6   // TSRX keyword ref.
 7   <input {ref focusInput} type="text" />
 8
 9   // Native-style ref attribute.
10   <input ref={focusInput} type="text" />
11
12   // Named ref prop on a host element.
13   <input focusRef={ref focusInput} type="text" />
14 }

A ref value can be a callback, a mutable variable in named-ref form, or the target framework's ref object, such as React/Preact useRef(), Vue ref(), or a Ripple Tracked. Ripple and Solid also support mutable variables on direct host refs. You may combine one ref={...} with any number of anonymous or named {ref ...} expressions on one host element; TSRX merges them. Solid also accepts an array in ref={...}.

 1 import { useRef } from 'react';
 2
 3 component SearchBox() {
 4   // Use the target's native ref object when you want an object slot.
 5   const inputRef = useRef<HTMLInputElement>(null);
 6   function logNode(node: HTMLInputElement) { console.log(node); }
 7
 8   // Native ref object + anonymous and named callback refs.
 9   <input ref={inputRef} {ref logNode} logRef={ref logNode} type="text" />
10 }

For reusable components, use a named ref prop when the component API needs a specific ref name. Refs can be forwarded explicitly or through {...props}, and mutable variables can be used as ref values.

Vue keeps ref={...} on components as Vue's native component ref; it is not forwarded to a DOM element inside the component. Use a named inputRef={ref input} or anonymous {ref input} when a Vue component should expose an inner DOM ref.

 1 component Field(props) {
 2   // The named ref prop is normalized into the real DOM ref.
 3   <input {...props} />
 4 }
 5
 6 component Form() {
 7   let input: HTMLInputElement | undefined;
 8
 9   // The compiler creates the assignment callback.
10   <Field inputRef={ref input} />
11 }

Named ref props are marked at runtime. Use isRefProp(value) to detect one; anonymous {ref ...} props are intentionally not publicly inspectable because their prop keys are unique symbols.

Lexical scoping

Every nested element and component body creates its own lexical scope. You can declare variables, compute derived values, or call functions inside an element's children — they're scoped to that block and won't leak into the surrounding component.

 1 component App() {
 2   const name = 'World';
 3
 4   <div>
 5     // This is a new scope — you can declare variables here
 6     const greeting = `Hello, ${name}!`;
 7     <h1>{greeting}</h1>
 8   </div>
 9 }

This applies to all block-like contexts: element children, component children, and control flow branches (if, for, switch, try). Each one has its own scope.

Children

Composite components accept children as nested JSX — just like HTML elements. However, children must be JSX statements, not expressions. You can't pass an array, function, or other value as children between the opening and closing tags.

When you need to pass a computed value, use the children prop explicitly instead:

 1 // Children as nested JSX — this works:
 2 <Card>
 3   <h2>"Title"</h2>
 4   <p>"Content goes here."</p>
 5 </Card>
 6
 7 // Passing a function or array? Use the children prop:
 8 <List children={items.map(renderItem)} />

Render props: Render props are just function-valued props in TSRX. Pass them explicitly as children={...} or a named prop like render={...}. If that function returns native TSRX, wrap it in <tsrx>...</tsrx>. Use <>...</> or <tsx>...</tsx> for JSX-style values.

 1 component App({ items }: { items: Item[] }) {
 2   const renderItem = (item: Item) => <>
 3     <li>{item.label}</li>
 4   </>;
 5
 6   <List items={items} render={renderItem} />
 7 }

Conditional rendering

Use standard if / else if / else statements directly inside templates. No ternaries or wrapper components needed.

 1 component StatusBadge({ status }: { status: string }) {
 2   <div>
 3     if (status === 'active') {
 4       <span class="badge active">"Online"</span>
 5     } else if (status === 'idle') {
 6       <span class="badge idle">"Away"</span>
 7     } else {
 8       <span class="badge">"Offline"</span>
 9     }
10   </div>
11 }

List rendering

Render lists with for...of loops. TSRX extends the syntax with optional index and key clauses so you don't need separate counters or key-extraction boilerplate.

Use continue inside a component for...of body to skip the current item. Top-level return and break statements are not allowed there, because loop rendering lowers to target-specific callbacks or blocks. Nested functions inside the loop keep ordinary JavaScript control flow.

Other JavaScript loops are not component rendering constructs: regular for, for...in, while, and do...while are rejected in component template scope. Move imperative loops into a nested function or effect, or render collections with for...of.

1 component TodoList({ items }: { items: Todo[] }) {
2   <ul>
3     for (const item of items; index i; key item.id) {
4       if (item.hidden) continue;
5       <li>{i + 1}". "{item.text}</li>
6     }
7   </ul>
8 }

Switch statements

Multi-branch rendering uses a standard switch statement. Each case can contain JSX elements and ends with break.

 1 component StatusMessage({ status }: { status: string }) {
 2   switch (status) {
 3     case 'loading':
 4       <p>"Loading..."</p>
 5       break;
 6     case 'success':
 7       <p class="success">"Done!"</p>
 8       break;
 9     default:
10       <p>"Unknown status."</p>
11   }
12 }

Error boundaries

Wrap components in try / catch to create error boundaries. If a child component throws, the catch block renders fallback UI.

1 component SafeProfile({ userId }: { userId: string }) {
2   try {
3     <UserProfile id={userId} />
4   } catch (error) {
5     <div class="error">
6       <p>"Something went wrong."</p>
7     </div>
8   }
9 }

Async boundaries

Wrap a component subtree in try / pending / catch to handle async children. While a lazy child or resource is in flight, the pending branch renders; when it resolves, the try body takes over; if it rejects — or any child throws synchronously — catch runs. Both pending and catch are optional and can be used independently.

Async work itself is expressed using the target's own lazy-loading primitive — lazy() from react, preact/compat, or solid-js, defineVaporAsyncComponent() on Vue Vapor, and trackAsync() on Ripple. In Solid, Vue, and Ripple targets, component bodies are synchronous and do not allow inline await. On React and Preact, top-level component-body await is supported and TSRX emits an async component function.

React/Preact note: for await...of is not supported inside component templates. Use an upstream async helper and render the resolved data.

 1 const UserProfile = lazy(() => import('./UserProfile.tsrx'));
 2
 3 export component App() {
 4   try {
 5     <UserProfile id={1} />
 6   } pending {
 7     <p>"Loading..."</p>
 8   } catch (e) {
 9     <p>"Something went wrong."</p>
10   }
11 }

Scoped styles

Each component can include a <style> block. Styles are automatically scoped — the compiler rewrites selectors with a unique hash so they only apply to elements inside the component that defines them. A parent's scoped styles will not leak into child components.

 1 component Card() {
 2   <div class="card">
 3     <h2>"Scoped title"</h2>
 4     <p>"Styles here won't leak out."</p>
 5   </div>
 6
 7   <style>
 8     .card {
 9       padding: 1.5rem;
10       border: 1px solid #ddd;
11     }
12
13     h2 { color: #333; }
14   </style>
15 }

To escape scoping and apply styles globally, wrap a selector in :global(). This lets you reach into child components or target elements outside the current scope.

Style composition

Because scoped styles don't cross component boundaries, TSRX provides {style "className"} for passing scoped class names to child components as props. {style "highlight"} produces a string containing both the scope hash and the class name, which the child applies via its class attribute.

 1 component Badge({ className }: { className?: string }) {
 2   <span class={`badge ${className ?? ''}`}>
 3     "New"
 4   </span>
 5
 6   <style>
 7     .badge { padding: 0.25rem 0.5rem; }
 8   </style>
 9 }
10
11 component App() {
12   <Badge className={style 'highlight'} />
13
14   <style>
15     .highlight { background: #e8f5e9; color: #2e7d32; }
16   </style>
17 }

The class referenced via {style} must exist as a standalone selector in the component's <style> block. Classes that only appear in compound or descendant selectors cannot be passed.

Early returns

Guard clauses use a bare return; with no value. Everything before the return is rendered; everything after is skipped. This replaces the pattern of returning early from a render function.

 1 component Dashboard({ user }: { user: User | null }) {
 2   if (!user) {
 3     <p>"Please sign in."</p>
 4     return;
 5   }
 6
 7   <h1>"Welcome, "{user.name}</h1>
 8   <p>"Here is your dashboard."</p>
 9 }

Note: return; is a guard exit only — return <JSX /> and return value are not valid in component bodies.

React & Preact Hooks

When targeting React with @tsrx/react or Preact with @tsrx/preact, hooks that appear in conditional, loop, switch, try, or expression-position <tsrx>...</tsrx> scopes are isolated behind generated child components when the compiler can do so safely. Hook callbacks may read outer values and mutate branch-local or module-level bindings, but assigning hook results or mutating a parent binding across the generated component boundary is a compiler error. Move that state into an explicit child component when values need to cross the boundary.

Both targets also support top-level component-body await and TSRX will emit an async component function when it appears in the component body.

 1 import { useState, useEffect } from 'react';
 2
 3 component UserProfile({ user, posts }: { user: User | null; posts: Post[] }) {
 4   if (!user) {
 5     <p>"Please sign in."</p>
 6     return;
 7   }
 8
 9   // Hooks after a guard are isolated safely
10   const [tab, setTab] = useState('overview');
11
12   <h1>{user.name}</h1>
13   <TabBar value={tab} onChange={setTab} />
14
15   // Hook results inside loops must stay local
16   <ul>
17     for (const post of posts) {
18       useEffect(() => {
19         console.log(`viewed ${post.title}`);
20       }, [post.title]);
21
22       <li>{post.title}</li>
23     }
24   </ul>
25 }

Released under the MIT License.

Copyright © 2025-present Dominic Gannaway