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. All text content must be wrapped in {} expression braces.

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 JSX as a value — to assign it, pass it as a prop, or return it from a helper — wrap it in <>...</> or the explicit <tsx></tsx>:

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

Text expressions

Because JSX is statement-based, bare text can't sit directly inside an element the way it does in React. Every piece of text content — even literal strings — lives inside an expression container { ... }. Each container holds a single JS/TS expression: a string literal, a template literal, a variable, a function call, a ternary, anything that evaluates to a value.

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 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

Ripple only. The {html expr} container is a Ripple-specific escape hatch for inserting pre-rendered markup directly into the DOM, similar in spirit to Svelte's {@html ...}. The expression must evaluate to a string, and it replaces the surrounding element's contents as parsed HTML rather than escaped text.

1 component Article({ markup }: { markup: string }) {
2   // {html ...} inserts the value as raw HTML — no escaping.
3   // Only use on trusted input; this is equivalent to innerHTML.
4   <article>{html markup}</article>
5 }

Only one {html ...} may sit inside an element, and it can't share the element with sibling children. Like text, the html keyword is compile-time only and disappears after the transform — in Ripple it lowers to an internal _$_.html binding.

Neither React, Preact, nor Solid have a cleanly-matching child-level primitive, so compiling {html ...} with @tsrx/react, @tsrx/preact, or @tsrx/solid is a compile-time error. For those targets, reach for the attribute-level equivalent directly: dangerouslySetInnerHTML={{ __html: markup }} on React and Preact, or innerHTML={markup} on Solid.

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). &{ ... } 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 it compiles to direct property access on the source object, in Ripple and Solid it preserves tracked/signal 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 the {ref expr} attribute to capture the underlying DOM element. expr can be a mutable let variable — the compiler targets assign the element to it — or a callback function, which is invoked with the element. Both forms work across the React, Preact, Solid, and Ripple targets.

 1 component AutoFocus() {
 2   let input: HTMLInputElement | undefined;
 3
 4   // {ref expr} — mutable variable assignment (Solid/Ripple/React)
 5   <input {ref input} type="text" />
 6
 7   // Or as a callback that receives the DOM node
 8   <input {ref (node) => node.focus()} />
 9 }

Refs also work on composite components. The child receives the ref as a regular prop and can apply it to any DOM element — typically by spreading {...props}, which lets the target framework wire the ref through automatically. You can place {ref ...} multiple times on the same element or component; every callback fires with the same element once it mounts.

 1 component TextField(props) {
 2   // Spreading props forwards the ref onto the underlying DOM element.
 3   <input {...props} />
 4 }
 5
 6 component Form() {
 7   let field: HTMLInputElement | undefined;
 8   function logNode(node: HTMLInputElement) {
 9     console.log(node);
10   }
11
12   // Multiple {ref ...} on the same element all fire.
13   <TextField {ref field} {ref logNode} />
14 }

Target details: React and Preact forward refs directly via the ref prop. Solid uses its built-in ref prop on composites (and applyRef for array refs so multiple callbacks fire). Ripple uses a per-ref Symbol key so refs survive {...rest} spreading without colliding with user-named props.

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)} />

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.

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

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, and the Ripple equivalent on the Ripple target. In Solid 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 for passing scoped class names to child components as props. #style.className 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, the compiler lifts all hook calls to the top of the generated function — regardless of where they appear in the TSRX source. That means hooks can appear after early returns, inside conditional blocks, or even inside loops, and the compiled output still satisfies the Rules of Hooks.

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 — this is fine in TSRX
10   const [tab, setTab] = useState('overview');
11
12   <h1>{user.name}</h1>
13   <TabBar value={tab} onChange={setTab} />
14
15   // Hooks inside a loop — the compiler extracts them
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