Back to blog

May 29, 2026

Simplifying TSRX

We listened to your feedback, TSRX is becoming smaller, clearer, and easier to debug.

PR #1177 is a big cleanup pass for TSRX. The goal is simple: keep the parts that make UI authoring nicer, and remove the parts that made the language feel unfamiliar.

This came from feedback from humans and companies experimenting with TSRX. It also came from evaluations of AI-generated output. The same pattern kept showing up: people and tools liked inline UI structure, but got confused by new concepts when debugging, especially the lack of a normal return statement, early-return magic, and too many TSRX-only syntax points.

Components are just functions

Before this cleanup, TSRX introduced its own component form. It removed the visible return statement, which made components look less like ordinary TypeScript.

 1 component Profile({ user }: { user: User | null }) {
 2   if (!user) {
 3     return;
 4   }
 5 
 6   const displayName = user.name.trim();
 7 
 8   if (user.isAdmin) {
 9     <p>{displayName}" can manage settings."</p>
10   }
11 
12   const title = <tsx><strong>{displayName}</strong></tsx>;
13   <Card {title} />
14 }

Now TSRX is an expression you return from an ordinary function. A component can return a single element directly when that is all it needs.

1 function Button({ label, onClick }: Props) {
2   return <button {onClick}>{label}</button>;
3 }

We also removed the plain <tsx> island. TSRX itself is expression-based now: native elements and native fragments can be assigned, passed as props, or returned directly. The old <tsrx> wrapper is not needed either because TSRX is already the default in .tsrx files.

1 function Profile({ user }: { user: User }) {
2   const displayName = user.name.trim();
3   const title = <strong>{displayName}</strong>;
4 
5   return <Card {title} />;
6 }

Native fragments offer the same benefits <tsx> was meant to provide. Keeping multiple ways to do the same thing did not add much once one path was clearer, more consistent, and easier for tools to explain.

When you return a fragment, its body is statement-driven: you can declare values, branch with if, loop with for...of, and emit UI in order.

 1 function Dashboard({ items }: Props) {
 2   return <>
 3     const visibleItems = items.filter((item) => !item.hidden);
 4 
 5     <ul>
 6       for (const item of visibleItems; key item.id) {
 7         <li>{item.label}</li>
 8       }
 9     </ul>
10 
11     try {
12       <ActivityFeed />
13     } pending {
14       <p>"Loading activity..."</p>
15     } catch (error) {
16       <p>"Could not load activity."</p>
17     }
18   </>;
19 }

All the useful TSRX control flow remains as-is: lists can still be rendered with for...of, async boundaries can still use try / pending / catch, and the template still reads in the order the UI is produced.

No more return magic inside templates

The earlier component model also allowed template bodies to bail out with a return. That looked convenient, but it made debugging strange because UI output and component exit behavior were mixed together.

1 component Profile({ user }: { user: User | null }) {
2   if (!user) {
3     return;
4   }
5 
6   <h1>{user.name}</h1>
7 }

Now guard returns live before TSRX opens, just like normal TypeScript and JSX. Inside template for...of loops, continue still skips the current item.

1 function Profile({ user }: { user: User | null }) {
2   if (!user) {
3     return null;
4   }
5 
6   return <h1>{user.name}</h1>;
7 }

React and Preact still get one useful target-specific compiler pass here. If a guard return appears before a hook, TSRX can keep the source simple while compiling the hook path into a shape that preserves hook ordering.

 1 function Profile({ user }: { user: User | null }) {
 2   if (!user) {
 3     return null;
 4   }
 5 
 6   useEffect(() => {
 7     trackProfile(user.id);
 8   }, [user.id]);
 9 
10   return <ProfileCard {user} />;
11 }

Fewer special cases

Ripple had accumulated a few TSRX-only shortcuts inside the old component shape: raw HTML blocks, ref blocks, and style class expressions. They worked, but each one was another rule to remember and another thing for tools to explain.

 1 component Article({ contentHtml, inputRef }: Props) {
 2   <article>{html contentHtml}</article>
 3   {html contentHtml}
 4   <input {ref inputRef} />
 5   <Child class={style 'card'} />
 6 
 7   <style>
 8     .card { padding: 1rem; }
 9   </style>
10 }

Those are now regular props. Ripple, Solid, and Vue use innerHTML on DOM elements. Ripple and Solid can also use <Fragment innerHTML={...} /> when you want to insert HTML without a wrapper element. React and Preact use dangerouslySetInnerHTML. Refs use ref={...}, and scoped classes can be shared with children through const styles = <style>...</style>.

React TSRX does not rewrite an authored class prop into className. Use className when your React component expects it; the compiler will not hide that difference for you.

For scoped styles on React host elements, TSRX still emits className for the generated CSS hash. That is generated output, not a rewrite of a class you wrote yourself.

Removing the old class to className magic makes generated code easier for humans and LLMs to predict.

The style expression is just a value. Put it before the returned content, then pass styles.card anywhere that value is in scope.

 1 function Article({ contentHtml, inputRef }: Props) {
 2   const styles = <style>
 3     .card { padding: 1rem; }
 4   </style>;
 5 
 6   return <>
 7     // Ripple, Solid, and Vue host elements
 8     <article innerHTML={contentHtml} />
 9 
10     // Ripple and Solid fragment output
11     <Fragment innerHTML={contentHtml} />
12 
13     // React and Preact host elements
14     <article dangerouslySetInnerHTML={{ __html: contentHtml }} />
15 
16     <input ref={inputRef} />
17 
18     // Ripple, Preact, Solid, and Vue component props
19     <Child class={styles.card} />
20 
21     // React component props
22     <Child className={styles.card} />
23   </>;
24 }

Styling stays optional

The <style> block is still there, but its mental model is clearer: styles are scoped to the TSRX fragment that contains them, not to a separate component declaration form. It works the same way as before for colocated styles.

Inside <style>, write static CSS. The TSRX rules for JavaScript statements and expressions inside elements do not apply there: no if, for, or {expr}. When a style value needs to change at runtime, set a CSS custom property on the element and read it with var(...).

 1 function Card({ title, tone }: Props) {
 2   return <>
 3     <article class="card" style={{ '--card-tone': tone }}>
 4       <h2>{title}</h2>
 5     </article>
 6 
 7     <style>
 8       .card {
 9         padding: 1rem;
10         border-radius: 0.75rem;
11         border-color: var(--card-tone);
12       }
13     </style>
14   </>;
15 }

When you move styles into a value before the returned template, the model is intentionally more like StyleX and other popular CSS-in-JS solutions: the CSS is still static, but you get a declarative class map, and you pass those class names explicitly where you need them.

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

This is optional. If your team prefers Tailwind, CSS modules, vanilla CSS, or a design system class API, keep using that. TSRX does not require colocated <style> blocks.

1 function Card({ title }: Props) {
2   return <article class="rounded-xl p-4">{title}</article>;
3 }

Why this matters

TSRX should make UI code easier to read, not harder to reason about. These changes make the language closer to TypeScript, closer to JSX, and easier for both people and AI tools to debug.

The result is a smaller language with the same core idea: open TSRX from an expression, then write clear statement-based UI inside it.

Released under the MIT License.

Copyright © 2025-present Dominic Gannaway