June 8, 2026
Rethinking TSRX
TSRX is leaning back into JSX, and making its specialized syntax look special instead of masquerading as ordinary JavaScript.
Ripple started with an idea we still believe in: let UI templates keep the power of JavaScript close to the markup that uses it. As TSRX grew out of that work, we also learned where that idea needed clearer boundaries, especially for codebases that have to scale across teams, tooling, and AI-assisted development.
The previous post,Simplifying TSRX after feedback, covered the first big correction: components became ordinary functions, returns became ordinary returns, and TSRX values became easier to store, pass, and type. That change moved TSRX closer to TypeScript and JSX. This post is about the next correction: making sure the specialized parts of TSRX no longer look like native JavaScript by accident.
The feedback was consistent. People could not always tell the difference between
JavaScript that ran as normal component logic and JavaScript-shaped syntax that meant
"create this UI." Earlier control flow looked too much like nativeif,switch,try, andfor. That made code harder for humans to scan and harder for LLMs to explain or edit
safely, because custom template semantics were masquerading as real JavaScript with
unclear indicators of what they were producing.
We also heard loud and clear that forcing static text through""or{''}was annoying and unintuitive. JSX text is one of JSX's most useful affordances, and
TSRX should preserve backwards compatibility with JSX instead of asking people to
relearn something that already works. The same applies to composition: teams want to
return a single element, a fragment with multiple elements, or a JSX control flow
value without each case feeling like a separate language feature.
That is the spirit behind this update. TSRX keeps the parts that make templates expressive, but it becomes more honest about where template syntax begins, where JavaScript begins, and how values flow through the system. Crucially, TSRX is now expression-based like JSX, rather than statement-based as it originally started.
It is JSX again
Before this change, TSRX static text had to opt out of being parsed as template statements. That meant simple JSX-looking markup often carried extra quotes around the text, even when there was nothing dynamic going on.
1 const Greeting = (): JSX.Element => <div>"Hello there!"</div>;
2
3 const EmptyState = ({ search }: EmptyStateProps): JSX.Element => <section>
4 <h2>"No results for "{search}</h2>
5 <p>"Try a different search term."</p>
6 </section>;That worked, but it made everyday markup feel like a dialect of JSX instead of JSX itself. The most important part of this update is also the most ordinary: if you know JSX, the template surface should now feel familiar. Static text is JSX text. Dynamic values use expression braces. Multiple children live in an element or fragment.
1 const Greeting = (): JSX.Element => <div>Hello there!</div>;
2
3 const EmptyState = ({ search }: EmptyStateProps): JSX.Element => <section>
4 <h2>No results for {search}</h2>
5 <p>Try a different search term.</p>
6 </section>;That removes a small but persistent source of friction.<div>Hello there!</div>now means what JSX users expect it to mean. Inside{}, write JavaScript expression syntax the same way you would in JSX or React: call a
formatter, read a property, choose a value, or pass a computed prop.
Comments stay comments
TSRX also permits JavaScript comments between JSX children, including multiline block comments. They are useful for keeping intent close to a tricky bit of markup, and they do not render as text.
1 const Toolbar = (): JSX.Element => <nav>
2 // Primary actions stay first for keyboard users.
3 <button>Save</button>
4 <button>Publish</button>
5
6 /*
7 * Secondary actions can be grouped later.
8 */
9 <a href="/settings">Settings</a>
10 </nav>;JSX statement containers
JSX already has expression containers:{value}. TSRX adds the statement version:@{...}. It is still an expression, so it can appear anywhere JSX can appear: as a component
body, as an assigned value, as a returned value, or as a child inside another element.
The only difference is that it can now also contain JavaScript statements and yield
JSX template when placed at the end.
1 const App = (): JSX.Element => @{
2 const message = formatMessage(user);
3
4 <p>{message}</p>
5 };JSX statement containers are just expressions, so you can assign them to variables and use them like any other JSX.
1 const summary: JSX.Element = @{
2 const count = items.length;
3 const label = count === 1 ? 'item' : 'items';
4
5 <p>{count} {label}</p>
6 };
7
8 return <aside>{summary}</aside>;A statement container ends with exactly one output node: a JSX element, a JSX
fragment, or JSX control flow like@if. Multiple elements need a fragment, and plain JSX text needs one too. Once that
output appears, the container is done: no JavaScript logic can come after it.
The@is the important boundary. If you write setup statements followed by a bare JSX
element in a normal{}function body, the compiler will tell you to add the missing@so the body becomes@{...}.
1 const App = (): JSX.Element => @{
2 const title = getTitle();
3
4 // Text and multiple children are wrapped in one fragment.
5 <>
6 Plain text goes in a fragment.
7 <h2>{title}</h2>
8 </>
9 };Statement containers as component bodies
Most TSRX components can return JSX directly. When the body needs local setup before rendering, return a statement container directly instead of wrapping it in an empty fragment. Fragments are still there for real multiple-child output; they just are not ceremony.
1 type ProductCardProps = {
2 product: Product;
3 };
4
5 const ProductCard = ({ product }: ProductCardProps): JSX.Element => @{
6 const price = formatCurrency(product.price);
7
8 <article>
9 <h2>{product.name}</h2>
10 <p>{price}</p>
11 </article>
12 };A named function can use the same body shape. Ordinary JavaScript guard returns still work before the final template output.
1 export function UserBadge({ user }: UserBadgeProps): JSX.Element @{
2 if (!user) {
3 return <span class="muted">Signed out</span>;
4 }
5
6 const initials = user.name.slice(0, 2).toUpperCase();
7
8 <button title={user.name}>{initials}</button>
9 }Top-level early returns are permitted when@{...}is the body of the component function. They behave like ordinary component guard
returns: leave early when you need to, otherwise finish the statement container with
one final template output.
1 export function AccountPanel({ user }: AccountPanelProps): JSX.Element @{
2 if (!user) {
3 return <a href="/login">Sign in</a>;
4 }
5
6 const displayName = user.name.trim();
7
8 <section>
9 <h2>{displayName}</h2>
10 <p>{user.plan}</p>
11 </section>
12 }React and Preact hooks stay explicit
TSRX does not try to make conditional React or Preact hooks safe by moving them into generated child components. Hook ordering is runtime behavior, especially for effects, so a branch that owns hook state should be an explicit component with a name you can reason about.
1 const DetailsPanel = ({ open }: DetailsPanelProps): JSX.Element => @{
2 const [selected, setSelected] = useState('summary');
3
4 @if (open) {
5 <DetailsEditor />
6 } @else {
7 <button onClick={() => setSelected('details')}>Showing {selected}</button>
8 }
9 };
10
11 function DetailsEditor(): JSX.Element {
12 const [draft, setDraft] = useState('');
13
14 return <Editor value={draft} onInput={setDraft} />;
15 }JSX control flow
Control flow is now explicit JSX syntax. Use@if,@for,@switch, and@trywhen the control flow itself should produce template output. Each branch or block
follows the same structural boundary as@{...}: optional TypeScript setup first, then a single rendered template output.
The@is doing real work here. A bareforlooks like JavaScript control flow, so both humans and AI agents naturally reach for
JavaScript rules:break,continue, fallthrough, or branch-local returns. Template control flow is different: it is JSX
that chooses what to render. The prefix makes that boundary visible before anyone has
to infer it from context.
1 const ActivityPanel = ({ result }: ActivityPanelProps): JSX.Element => @{
2 @try {
3 const activity = result.value;
4 const latest = activity[0];
5
6 @if (latest) {
7 <ActivityCard activity={latest} />
8 } @else {
9 <p>No activity yet</p>
10 }
11 } @pending {
12 <p>Loading activity...</p>
13 } @catch (error) {
14 <p>{getErrorMessage(error)}</p>
15 }
16 };Here is what that looked like — the same list written with a bare loop, where reaching
for a JavaScript-stylecontinueto skip an item seems perfectly natural:
1 const ProductList = ({ products }: ProductListProps): JSX.Element => @{
2 <ul>
3 for (const product of products) {
4 if (!product.available) {
5 continue;
6 }
7
8 <li>{product.name}</li>
9 }
10 </ul>
11 };The prefixed loop removes that ambiguity and uses the same shape: setup can happen at
the top of each iteration, then the loop body produces one output node. If some items
should not render, filter the iterable before the loop. If the list is empty, use the
loop's@emptybranch instead of smuggling acontinueorreturninto template control flow.
1 const ProductList = ({ products }: ProductListProps): JSX.Element => @{
2 const visibleProducts = products.filter((product) => product.available);
3
4 <ul>
5 @for (const product of visibleProducts; key product.id) {
6 const price = formatCurrency(product.price);
7
8 <li>
9 <span>{product.name}</span>
10 <strong>{price}</strong>
11 </li>
12 } @empty {
13 <li>No products available</li>
14 }
15 </ul>
16 };When a branch needs more than one rendered child, the one output node is simply a fragment. That keeps the shape predictable without making branches feel cramped.
1 const Profile = ({ user }: ProfileProps): JSX.Element => @{
2 @if (user) {
3 const displayName = user.name.trim();
4
5 <>
6 <h2>{displayName}</h2>
7 <p>{user.bio}</p>
8 </>
9 } @else {
10 <a href="/login">Sign in</a>
11 }
12 };Because JSX control flow is explicitly template syntax, it can also be assigned
directly to variables. You no longer need to wrap aswitchbranch in a helper just to store it.
1 const StatusBadge = ({ status }: StatusBadgeProps): JSX.Element => {
2 const badge: JSX.Element = @switch (status) {
3 @case 'active': {
4 <strong>Active</strong>
5 }
6 @case 'idle': {
7 <span>Idle</span>
8 }
9 @default: {
10 <span>Offline</span>
11 }
12 };
13
14 return <header>{badge}</header>;
15 };This is also where precise types become more useful. A stored element can be annotated
asJSX.Element, and a component arrow can declare that it returnsJSX.Element. The type describes the value you are producing instead of depending on a special
component declaration form.
Conclusion
The result is easier to scan, easier to type, and easier for tools to understand.
Templates still have lexical scopes. Logic can still be colocated. JSX control flow
still reads in the order the UI is produced. But the special parts are now marked as
special with@, and ordinary JavaScript gets to remain ordinary JavaScript.
Thank you for bearing with us while TSRX found this shape. From now, we're ready to move into the beta phase and we do not anticipate more core syntax changes beyond major bug fixes. The focus shifts to hardening the compiler, runtime behavior, editor tooling, and documentation around this syntax.