Components

TSRX components are ordinary function declarations. Props are typed with a single parameter, normal JavaScript setup can run before the return, and the returned TSRX template contains the rendered structure, control flow, and scoped styles.

 1 export function Button({ label, onClick }: {
 2   label: string;
 3   onClick: () => void;
 4 }) {
 5   return <>
 6     // Ripple, Preact, Solid, and Vue host elements:
 7     <button class="btn" {onClick}>{label}</button>
 8 
 9     // React host elements:
10     // <button className="btn" {onClick}>{label}</button>
11 
12     <style>
13       .btn {
14         padding: 0.5rem 1rem;
15         border-radius: 4px;
16       }
17     </style>
18   </>;
19 }

Export components with export function exactly as you would in TSX. The compiler transforms returned TSRX into standard framework components for whichever target you're using.

Since returned TSRX templates have their own template rules (see TSRX templates), anything that needs ordinary JavaScript-only control flow 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, outside the returned template rules.

 1 export function Counter() {
 2   return <>
 3     let count = 0;
 4 
 5     // Plain JS/TS control flow can live in nested functions.
 6     const increment = () => {
 7       if (count >= 10) {
 8         count = 0;
 9       } else {
10         count += 1;
11       }
12     };
13 
14     <button onClick={increment}>"Count: "{count}</button>
15   </>;
16 }

TSRX templates

TSRX opens from ordinary JSX expression positions such as return <div /> or return <>...</>. Once you are inside that returned template, control flow, locals, and nested elements can appear directly in the tree. Static text can be written as direct double-quoted children, while dynamic values and JavaScript expressions still use {}.

1 function Greeting() {
2   return <>
3     <h1>"Hello World"</h1>
4     <p>"Welcome to TSRX."</p>
5   </>;
6 }

When you need a native TSRX subtree as a value inside another expression — for example to assign it to a variable or pass it as a prop — wrap it in <>...</> when it needs statements or multiple elements:

1 function App() {
2   const title = <><span class="title">"Settings"</span></>;
3 
4   return <>
5     <Card {title} />
6   </>;
7 }

As in JSX, you don't need fragments for a single element:

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

Text expressions

Because TSRX templates parse text deliberately, 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 function Inbox({ name, count }: { name: string; count: number }) {
2   return <>
3     <p>"Hello, "{name}"!"</p>
4     <p>{'You have ' + count + ' unread messages'}</p>
5     <p>{count > 0 ? 'Check your inbox.' : 'All caught up.'}</p>
6   </>;
7 }

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, Vue). &{ ... } preserves that reactivity while keeping the ergonomic destructuring syntax:

1 function UserCard(&{ name, age }: { name: string; age: number }) {
2   return <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} />

Lexical scoping

Every returned TSRX template and nested element 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 function.

1 function App() {
2   const name = 'World';
3 
4   return <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 <>...</> when it needs statement-oriented children.

1 function App({ items }: { items: Item[] }) {
2   const renderItem = (item: Item) => <>
3     <li>{item.label}</li>
4   </>;
5 
6   return <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 function StatusBadge({ status }: { status: 'active' | 'idle' | 'offline' }) {
 2   return <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 template for...of body to skip the current item. return statements are invalid anywhere inside a TSRX element or fragment body. break is not allowed in for...of template loop bodies; use break only for switch cases. Nested functions keep ordinary JavaScript control flow.

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

1 function TodoList({ items }: { items: Todo[] }) {
2   return <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 function StatusMessage({ status }: { status: string }) {
 2   return <>
 3     switch (status) {
 4       case 'loading':
 5         <p>"Loading..."</p>
 6         break;
 7       case 'success':
 8         <p class="success">"Done!"</p>
 9         break;
10       default:
11         <p>"Unknown status."</p>
12     }
13   </>;
14 }

Error boundaries

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

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

The catch block also receives a reset function as its second argument. Calling reset() clears the error state and re-renders the children, which is useful for building retry UIs:

 1 export function RetryBoundary() {
 2   return <>
 3     try {
 4       <ComponentThatMightFail />
 5     } catch (e, reset) {
 6       <div>
 7         <p>"Error: "{e.message}</p>
 8         <button onClick={() => reset()}>"Try again"</button>
 9       </div>
10     }
11   </>;
12 }

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, returned TSRX templates are synchronous and do not allow inline await. On React and Preact, template-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 function App() {
 4   return <>
 5     try {
 6       <UserProfile id={1} />
 7     } pending {
 8       <p>"Loading..."</p>
 9     } catch (e) {
10       <p>"Something went wrong."</p>
11     }
12   </>;
13 }

Scoped styles

Each returned TSRX template 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 function return that defines them. A parent's scoped styles will not leak into child components.

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

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.

React note: TSRX keeps authored attributes as written. Use className on React host elements and React component props, just like ordinary JSX. Compiler-injected scoped style hashes still use React's className output when needed.

Style composition

Because scoped styles don't cross component boundaries, assigning a <style> expression to a variable exposes a class map for passing scoped class names to child components as props. styles.highlight contains both the scope hash and the class name, which the child applies via its class prop. React components use className instead, matching React's normal prop names.

 1 function Badge({ class: className }: { class?: string }) {
 2   return <>
 3     <span class={'badge ' + (className ?? '')}>"New"</span>
 4 
 5     <style>
 6       .badge { padding: 0.25rem 0.5rem; }
 7     </style>
 8   </>;
 9 }
10 
11 function App() {
12   const styles = <style>
13     .highlight { background: #e8f5e9; color: #2e7d32; }
14   </style>;
15 
16   // Ripple, Preact, Solid, and Vue component props:
17   return <Badge class={styles.highlight} />;
18 
19   // React component props:
20   // return <Badge className={styles.highlight} />;
21 }

You can also create the style expression at module scope and reference the generated class map from any component in the file. This gives teams moving from StyleX-like patterns a declarative, reusable class map without putting the <style> block inside the returned template.

 1 const articleStyles = <style>
 2   .card { padding: 1rem; }
 3   .title { font-weight: 700; }
 4 </style>;
 5 
 6 export function ArticleCard({ title }: { title: string }) {
 7   // Ripple, Preact, Solid, and Vue host elements:
 8   return <article class={articleStyles.card}>
 9     <h2 class={articleStyles.title}>{title}</h2>
10   </article>;
11 
12   // React host elements:
13   // return <article className={articleStyles.card}>
14   //   <h2 className={articleStyles.title}>{title}</h2>
15   // </article>;
16 }

Classes exposed on the map come from standalone selectors in the <style> block. Classes that only appear in compound or descendant selectors are not exported on the map.

React & Preact Hooks

When targeting React with @tsrx/react or Preact with @tsrx/preact, hooks that appear in conditional, loop, switch, try, or returned-template <>...</> 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 template-body await and TSRX will emit an async component function when it appears in the returned template.

 1 import { useState, useEffect } from 'react';
 2 
 3 function UserProfile({ user, posts }: { user: User | null; posts: Post[] }) {
 4   if (!user) {
 5     return null;
 6   }
 7 
 8   const [tab, setTab] = useState('overview');
 9 
10   return <>
11     // Hooks inside TSRX template branches are isolated safely.
12 
13     <h1>{user.name}</h1>
14     <TabBar value={tab} onChange={setTab} />
15 
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   </>;
26 }

Released under the MIT License.

Copyright © 2025-present Dominic Gannaway