Components
A TSRX component is just a TypeScript function that produces JSX. Use a statement-container body for component-shaped templates, especially when local setup, comments, scoped styles, or multiple rendered children belong with the markup.
In practice, components are ordinary TypeScript functions or const values. A component can use @{...} as the function body, giving you one place for local state, derived values, template
control flow, rendered elements, and scoped styles.
1 export function Button({ label, onClick }: {
2 label: string;
3 onClick: () => void;
4 }) @{
5 <>
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 them like any other function: export function Name() @{ <div /> }. The compiler turns that into the right component shape for the target you're
using.
When a bit of logic should stay plain JavaScript rather than render into the
template, put it in a normal function beside the markup. Use function fn() { ... } for ordinary control flow, then call helpers from event handlers or expressions: >.
1 export function Counter() @{
2 let count = 0;
3
4 // Plain JS/TS control flow can live in nested functions.
5 function increment() {
6 if (count >= 10) {
7 count = 0;
8 } else {
9 count += 1;
10 }
11 }
12
13 <button onClick={increment}>Count: {count}</button>
14 }Statement containers
When a template scope mixes TypeScript setup with rendered output, wrap the setup in@{...}. TSRX treats everything before the final renderable child as script, then the
container must finish with exactly one output node.
That final output can be a JSX element, a JSX fragment, or JSX control flow like @if, @for, @switch, or @try. It cannot be a bare expression container, and no script statements can appear
after it.
If the rendered part needs multiple siblings or text next to elements, wrap those children in a fragment so they become one output. The rule applies locally to component bodies, element children, and control-flow branches, so setup can stay close to the markup that uses it without turning ordinary template text into JavaScript.
Control-flow bodies are implicit statement containers too:@if,@for,@switch, and@tryarms all use{}blocks.
If you write setup statements and then a bare JSX element inside a normal{}function body, the compiler will ask you to add the missing@. Plain braces are JavaScript; statement-container braces are@{...}.
1 function ProductCard({ product }: { product: Product }) @{
2 const name = product.name.trim();
3 const price = money(product.price);
4
5 <article>
6 <h2>{name}</h2>
7
8 <footer>@{
9 const hasDiscount = product.discount > 0;
10
11 <>
12 <strong>{price}</strong>
13 @if (hasDiscount) {
14 <span>Sale</span>
15 }
16 </>
17 }</footer>
18 </article>
19 }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 ofname={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 statement container and control-flow block creates its own lexical scope. You can declare variables, compute derived values, or call functions there — they're scoped to that block and won't leak into the surrounding function.
1 function 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
8 <h1>{greeting}</h1>
9 }</div>
10 }This applies to all block-like contexts: statement containers and control flow
branches (@if,@for,@switch,@try). Each one has its own scope.
Conditional rendering
Use@if/else if/elsetemplate expressions directly inside templates, or return them directly from a
component. Branches render their template output, while ordinaryifstatements in setup stay plain JavaScript for guard returns. Directreturn,continue, andbreakare not allowed inside@iftemplate branches.
1 function StatusBadge({ status }: { status: 'active' | 'idle' | 'offline' }) @{
2 @if (status === 'active') {
3 <span class="badge active">Online</span>
4 } @else if (status === 'idle') {
5 <span class="badge idle">Away</span>
6 } @else {
7 <span class="badge">Offline</span>
8 }
9 }List rendering
Render lists with@for (... of ...)loops. TSRX extends the syntax with optionalindexandkeyclauses so you don't need separate counters or key-extraction boilerplate. The
optional@emptybranch renders when the iterable has no items.
Filter the collection before passing it to@forwhen some items should not render. Use@emptyfor the no-items branch. Directcontinue,break, andreturnstatements are not allowed in@fortemplate loop bodies; nested functions keep ordinary JavaScript control flow.
Other JavaScript loops are not template rendering constructs: regularfor,for...in,while, anddo...whileare 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 const visibleItems = items.filter((item) => !item.hidden);
3
4 <ul>
5 @for (const item of visibleItems; index i; key item.id) {
6 <li>{i + 1}. {item.text}</li>
7 } @empty {
8 <li>No todos yet</li>
9 }
10 </ul>
11 }Switch statements
Multi-branch rendering uses an@switchtemplate expression. Eachcaseordefaulthas its own{}body, cases never fall through, andbreakandreturnare invalid inside the case body.
1 function StatusMessage({ status }: { status: string }) @{
2 @switch (status) {
3 @case 'loading': {
4 <p>Loading...</p>
5 }
6 @case 'success': {
7 <p class="success">Done!</p>
8 }
9 @default: {
10 <p>Unknown status.</p>
11 }
12 }
13 }Error boundaries
Wrap components in@try/catchto create error boundaries. If a child component throws, the catch block renders its
fallback children.
1 function 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 }Thecatchblock also receives aresetfunction as its second argument. Callingreset()clears the error state and re-renders the children, which is useful for building
retry UIs:
1 export function RetryBoundary() @{
2 @try {
3 <ComponentThatMightFail />
4 } @catch (e, reset) {
5 <div>
6 <p>Error: {e.message}</p>
7 <button onClick={() => reset()}>Try again</button>
8 </div>
9 }
10 }Async boundaries
Wrap a component subtree in@try/pending/catchto handle async children. While a lazy child or resource is in flight, thependingbranch renders; when it resolves, the@trybody takes over; if it rejects — or any child throws synchronously —catchruns. Bothpendingandcatchare optional and can be used independently. Each branch follows the same
setup-then-one-output rule as a statement container.
Async work itself is expressed using the target's own lazy-loading primitive —lazy()fromreact,preact/compat, orsolid-js,defineVaporAsyncComponent()on Vue Vapor, andtrackAsync()on Ripple. In Solid, Vue, and Ripple targets, returned TSRX templates are
synchronous and do not allow inlineawait. On React and Preact, template-bodyawaitis supported and TSRX emits an async component function.
React/Preact note:for await...ofis 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 @try {
5 <UserProfile id={1} />
6 } @pending {
7 <p>Loading...</p>
8 } @catch (e) {
9 <p>Something went wrong.</p>
10 }
11 }Dynamic elements and components
Use the dynamic tag syntax<{expression}>when the host element tag or component constructor is selected at runtime. The
expression can evaluate to a string tag name like'section'or a component value, and the closing tag repeats the same expression:</{expression}>. No import is required; each target compiler lowers the tag to its own runtime
helper.
The tag expression must be able to resolve to an element name: an identifier, member access, static string, or a runtime expression composed of those. Calls, spreads, string concatenation, string interpolation, and static non-string literals are not valid tag names.
1 type PanelProps = {
2 as?: 'section' | 'article';
3 item: Item;
4 expanded: boolean;
5 };
6
7 function Summary({ item }: { item: Item }) @{
8 <p>{item.summary}</p>
9 }
10
11 function Details({ item }: { item: Item }) @{
12 <article>{item.body}</article>
13 }
14
15 export function Panel({ as = 'section', item, expanded }: PanelProps) @{
16 const Body = expanded ? Details : Summary;
17
18 <{as} className="panel">
19 <{Body} item={item} />
20 </{as}>
21 }The example uses React'sclassNameprop. Ripple, Preact, Solid, and Vue useclassfor host classes. Removed dynamic tag forms like<@tag />and<@Component />are not part of current TSRX.
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 component fragment that
defines them. A parent's scoped styles will not leak into child components.
1 function Card() @{
2 <>
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. UseclassNameon React host elements and React component props, just like ordinary JSX.
Compiler-injected scoped style hashes still use React'sclassNameoutput 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.highlightcontains both the scope hash and the class name, which the child applies via itsclassprop. React components useclassNameinstead, matching React's normal prop names.
1 function Badge({ class: className }: { class?: string }) @{
2 <>
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 <>
17 // Ripple, Preact, Solid, and Vue component props:
18 <Badge class={styles.highlight} />
19
20 // React component props:
21 // <Badge className={styles.highlight} />
22 </>
23 }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 <article class={articleStyles.card}>
9 <h2 class={articleStyles.title}>{title}</h2>
10 </article>
11
12 // React host elements:
13 // <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.
JS comments
JavaScript line and block comments are valid inside template children. They are comments, not rendered text, so you can annotate element bodies and control-flow branches without wrapping the note in braces.