RSX Syntax
RSX is a JSX-like syntax for building UI in Rust. It lets you write HTML-like markup directly in your Rust code.
How RSX Works
The rsx! macro generates DOM construction code that:
- Creates DOM nodes via
RenderScope - Sets up Effects for reactive expressions
{|| ...} - Wires event handlers
This enables fine-grained reactive updates - when a signal changes, only the affected DOM nodes are updated, not the entire tree.
Component Signature
Components use the #[component] attribute macro and return a NodeHandle:
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[component]
fn my_component() -> NodeHandle {
rsx! {
div { "Hello from my component!" }
}
}
}
The #[component] macro injects the __scope: &mut RenderScope parameter automatically, which is used by rsx! to create DOM nodes and Effects.
Basic Syntax
#![allow(unused)]
fn main() {
use rinch::prelude::*;
#[component]
fn example() -> NodeHandle {
rsx! {
div {
h1 { "Hello, World!" }
p { "This is a paragraph." }
}
}
}
}
HTML Elements
Standard HTML elements are written in lowercase:
#![allow(unused)]
fn main() {
rsx! {
div {
span { "Text content" }
button { "Click me" }
input { type: "text", placeholder: "Enter text..." }
}
}
}
Attributes
Attributes are specified after the element name:
#![allow(unused)]
fn main() {
rsx! {
div { class: "container", id: "main",
a { href: "https://example.com", "Link text" }
img { src: "image.png", alt: "Description" }
}
}
}
Components
Components are custom UI elements written in PascalCase. They implement the Component trait and render directly to DOM nodes:
#![allow(unused)]
fn main() {
rsx! {
Button { variant: "filled", color: "blue", onclick: || println!("clicked"),
"Click me"
}
Alert { icon: Icon::InfoCircle, color: "blue", title: "Info",
"This is an informational message."
}
}
}
Note: Shell-level constructs like windows, menus, and themes are configured at the runtime level via props, not in RSX.
Fragments
Use empty braces to group multiple elements without a wrapper:
#![allow(unused)]
fn main() {
rsx! {
div {
// Multiple children without extra wrapper
span { "First" }
span { "Second" }
}
}
}
Text Content
Text can be included directly in elements:
#![allow(unused)]
fn main() {
rsx! {
p { "This is text content" }
span { "Multiple " "strings " "concatenated" }
}
}
Expressions
Rust expressions can be embedded in curly braces:
#![allow(unused)]
fn main() {
let name = "World";
let count = 42;
rsx! {
p { "Hello, " {name} "!" }
p { "Count: " {count} }
}
}
Reactive Expressions
For fine-grained reactivity, use closure syntax {|| expr} to create expressions that automatically update when signals change. Without the closure, values are captured once at initial render and never update.
Static vs Reactive
#![allow(unused)]
fn main() {
let count = Signal::new(0);
rsx! {
// ❌ STATIC: Captured once, never updates
p { "Count: " {count.get()} }
// ✅ REACTIVE: Creates an effect, updates when count changes
p { "Count: " {|| count.get().to_string()} }
}
}
Reactive Text
Wrap dynamic text in a closure to make it reactive:
#![allow(unused)]
fn main() {
let name = Signal::new("World".to_string());
let count = Signal::new(0);
rsx! {
// Simple reactive text
h1 { "Hello, " {|| name.get()} "!" }
// Reactive with formatting
p { {|| format!("You clicked {} times", count.get())} }
// Conditional text
p { {|| if count.get() > 10 { "Many clicks!" } else { "Keep clicking" }} }
}
}
Reactive Styles
Style attributes can also be reactive:
#![allow(unused)]
fn main() {
let progress = Signal::new(50);
let is_active = Signal::new(false);
rsx! {
// Reactive width
div {
class: "progress-bar",
style: {|| format!("width: {}%", progress.get())}
}
// Multiple reactive style properties
div {
style: {|| format!(
"background: {}; opacity: {}",
if is_active.get() { "blue" } else { "gray" },
if is_active.get() { "1" } else { "0.5" }
)}
}
}
}
Reactive Classes
Dynamically change CSS classes based on state:
#![allow(unused)]
fn main() {
let is_selected = Signal::new(false);
let variant = Signal::new("primary");
rsx! {
// Conditional class
div {
class: {|| if is_selected.get() { "item selected" } else { "item" }}
}
// Dynamic class from state
button {
class: {|| format!("btn btn-{}", variant.get())}
}
}
}
Important: Closure Must Be the Direct Expression
The {|| ...} pattern requires the closure to be the direct expression inside the braces. Block expressions with setup code now work:
#![allow(unused)]
fn main() {
let count = Signal::new(0);
rsx! {
// ✅ CORRECT: Closure is the direct expression
div { style: {|| format!("width: {}px", count.get() * 10)} }
// ✅ CORRECT: Block with setup + final closure — works!
div { style: {
let multiplier = 10;
move || format!("width: {}px", count.get() * multiplier)
}}
// ✅ CORRECT: Compute inside the closure
div { style: {move || {
let multiplier = 10;
format!("width: {}px", count.get() * multiplier)
}}}
}
}
You can compute intermediate values outside the RSX, inside the closure body, or in a setup block before the closure.
Why Closures?
Rust macros operate on syntax, not types. The macro cannot distinguish between:
signal.get()- reading reactive statehashmap.get("key")- reading a regular value
The closure {|| ...} explicitly marks the expression as reactive, telling Rinch to:
- Create an Effect that wraps this expression
- Track which signals are read inside the closure
- Re-run and update the DOM when those signals change
This approach has benefits:
- Zero runtime overhead for static content
- Clear intent - you know exactly what’s reactive
- Works with Rust’s ownership - closures capture values correctly
What Supports Reactive Expressions?
| Context | Fine-Grained? | Notes |
|---|---|---|
| Text content | ✅ Yes | `{ |
style attribute | ✅ Yes | Updates specific element’s style |
class attribute | ✅ Yes | Updates specific element’s class |
| Portal content | ✅ Yes | Content inside portals is reactive |
if/else blocks | ✅ Yes | Native reactive conditional rendering |
for loops | ✅ Yes | Native reactive list rendering with keyed reconciliation |
match blocks | ✅ Yes | Native reactive multi-branch rendering |
.iter().map() | ✅ Yes | Creates a display:contents wrapper for the Vec |
| Window/Menu | N/A | Native OS elements, not DOM |
Control Flow
Rinch supports native Rust control flow directly in RSX. All control flow is always reactive — the conditions, iterators, and scrutinees are automatically wrapped in closures and tracked by Effects. When the underlying signals change, only the affected branches are updated.
if / else
Write standard Rust if/else directly in RSX:
#![allow(unused)]
fn main() {
let visible = Signal::new(true);
let count = Signal::new(5);
rsx! {
div {
// Simple if
if visible.get() {
p { "I'm visible!" }
}
// if/else
if count.get() > 10 {
p { "Big number!" }
} else {
p { "Small number" }
}
// if/else if/else
if count.get() > 100 {
p { "Huge" }
} else if count.get() > 10 {
p { "Big" }
} else {
p { "Small" }
}
}
}
}
if let
Pattern matching with if let is also supported:
#![allow(unused)]
fn main() {
let current_user = Signal::new(Some("Alice".to_string()));
rsx! {
div {
if let Some(name) = current_user.get() {
p { "Welcome, " {name} "!" }
} else {
p { "Please log in" }
}
}
}
}
How if works internally
The if block desugars to show_dom() — the same runtime function used by the Show component. The condition is auto-wrapped in a move || { ... } closure, creating an Effect that watches the signals read inside it. When the condition changes:
- The old branch’s scope is disposed (cleaning up nested effects)
- Old DOM nodes are removed
- New branch content is rendered with a fresh scope
for loops
Write standard for..in loops directly in RSX:
#![allow(unused)]
fn main() {
let todos = Signal::new(vec![
Todo { id: 1, name: "Buy groceries".into() },
Todo { id: 2, name: "Write code".into() },
Todo { id: 3, name: "Take a walk".into() },
]);
rsx! {
div {
for todo in todos.get() {
div { key: todo.id, {todo.name.clone()} }
}
}
}
}
Keys
Use key: on the first child element or component to enable efficient keyed reconciliation. When the list changes, items with the same key are preserved (not re-rendered), and only new/removed items trigger DOM operations:
#![allow(unused)]
fn main() {
for item in items.get() {
div { key: item.id,
span { {item.name.clone()} }
}
}
}
key: also works on components — place it on the component tag directly:
#![allow(unused)]
fn main() {
for item in items.get() {
TodoRow { key: item.id, label: item.name.clone() }
}
}
If no key: prop is provided, items are keyed by their Debug representation (fallback).
How for works internally
The for loop desugars to for_each_dom_typed(), which:
- Wraps the iterator in a
move || { ... }closure (making it reactive) - Creates a
<!-- for -->comment marker in the DOM - Evaluates the collection and renders initial items
- Creates an Effect that watches the collection
- When the collection changes, uses LIS-based keyed reconciliation (
diff_keyed()) to compute minimal DOM operations: insert new items, remove deleted items, and reposition moved items - For surviving items (same key), compares data via
PartialEq— only re-renders items whose data actually changed
The loop variable is owned (T, not &T), so you can capture it directly in move closures. let bindings before the element are available in the key: expression too:
#![allow(unused)]
fn main() {
for todo in todos.get() {
let id = todo.id;
div { key: id,
{todo.name.clone()}
button {
onclick: move || todos.update(|t| t.retain(|t| t.id != id)),
"Delete"
}
}
}
}
Note: The item type must implement Clone + PartialEq + 'static for for loops to work. This enables efficient data comparison for selective re-rendering.
Reactivity in for Loops
The for loop expression itself is reactive — it reads a Signal, which creates an Effect. When that Signal changes, the loop re-evaluates and reconciles the list. Items whose data changed (per PartialEq) get their component re-created with fresh props. This means the component function runs again with the new values.
Because of this, closures on plain props inside for-loop components are unnecessary — the whole function runs again with new values when the item changes. Closures are needed for per-item Signals created with Signal::new() inside the loop body, since those change independently of the list data.
#![allow(unused)]
fn main() {
// Props are plain values from the for loop — no closure needed
#[component]
pub fn TodoItem(label: String, completed: bool) -> NodeHandle {
// ❌ Unnecessary: `completed` is a plain bool, not a Signal.
// The closure captures it once, but the component is re-created
// with a fresh `completed` value when the item data changes anyway.
rsx! { Text { style: {|| if completed { "text-decoration: line-through" } else { "" }} } }
// ✅ Simpler: just use the value directly
rsx! { Text { style: if completed { "text-decoration: line-through" } else { "" } } }
}
// Per-item Signal — closure IS needed
for todo in todos.get() {
let editing = Signal::new(false); // Per-item state
div { key: todo.id,
// ✅ Closure needed: `editing` is a Signal that changes independently
span { style: {|| if editing.get() { "outline: 1px solid blue" } else { "" }} }
}
}
}
Rule of thumb: If the value comes from a Signal (.get()), use a closure. If it comes from the for loop variable (a plain value), just use it directly.
Filtering and Transforming Collections
You can filter, map, and transform the collection inline in the for expression. The entire expression is wrapped in a reactive closure by the macro, so all Signals read inside it are tracked:
#![allow(unused)]
fn main() {
let todos = Signal::new(vec![/* ... */]);
let filter = Signal::new(Filter::All);
rsx! {
div {
// Filter the collection inline — .filter(), .map(), .collect() all work
for todo in todos.get().into_iter().filter(|t| {
match filter.get() {
Filter::All => true,
Filter::Active => !t.completed,
Filter::Completed => t.completed,
}
}).collect::<Vec<_>>() {
TodoItem { key: todo.id, label: todo.text.clone() }
}
}
}
}
Both todos and filter are tracked — the loop re-evaluates when either Signal changes. Any iterator chain that produces a Vec<T> (where T: Clone + PartialEq + 'static) works.
match
Write standard Rust match directly in RSX:
#![allow(unused)]
fn main() {
let tab = Signal::new(0);
rsx! {
div {
match tab.get() {
0 => div { "Home page" },
1 => div { "About page" },
2 => div { "Settings page" },
_ => div { "Page not found" },
}
}
}
}
Match with pattern bindings
Patterns that bind variables work — each arm re-evaluates the scrutinee to extract the bound values:
#![allow(unused)]
fn main() {
let result = Signal::new(Ok::<String, String>("Hello".into()));
rsx! {
div {
match result.get() {
Ok(value) => p { "Success: " {value} },
Err(msg) => p { class: "error", "Error: " {msg} },
}
}
}
}
Match with guards
Guard expressions are supported:
#![allow(unused)]
fn main() {
match score.get() {
n if n >= 90 => div { "A" },
n if n >= 80 => div { "B" },
n if n >= 70 => div { "C" },
_ => div { "F" },
}
}
How match works internally
The match block desugars to match_dom(), which generalizes show_dom() to N branches. A discriminant closure returns the index of the active branch (0, 1, 2, …). When the discriminant changes, the old branch is disposed and the new branch is rendered.
Programmatic Conditional Rendering (show_dom)
For cases requiring explicit control (e.g., lazy evaluation of children that contain hooks), use show_dom() directly:
#![allow(unused)]
fn main() {
show_dom(
__scope,
&parent,
move || visible.get(), // Condition closure
|scope| { // Then branch
let div = scope.create_element("div");
div.set_text("Visible!");
div
},
Some(|scope| { // Else branch (optional)
let div = scope.create_element("div");
div.set_text("Hidden");
div
}),
)
}
Programmatic List Rendering (for_each_dom_typed)
For cases requiring the raw list API, use for_each_dom_typed() directly:
#![allow(unused)]
fn main() {
for_each_dom_typed(
__scope,
&parent,
move || todos.get().into_iter().collect::<Vec<_>>(),
|todo| todo.id.to_string(),
|todo, __child_scope| {
let __scope = __child_scope;
rsx! { div { {todo.name.clone()} } }
},
)
}
How Keyed Reconciliation Works
When the list changes, for uses the LIS (Longest Increasing Subsequence) algorithm to find the minimum DOM operations:
#![allow(unused)]
fn main() {
// Before: ["a", "b", "c", "d"]
// After: ["b", "e", "c", "a"]
// Operations:
// - Remove "d"
// - Insert "e" at index 1
// - Move "a" to index 3
// - Items "b" and "c" stay in place (part of LIS)
}
This means:
- Unchanged items keep their DOM nodes (no re-render)
- Moved items are repositioned in DOM (no re-create)
- Changed items are re-rendered if their data differs (via
PartialEq) - Internal state (signals, effects) is preserved for unchanged items
- Only new items trigger component initialization
Note: The loop variable is owned (
T, not&T), so you can capture it directly inmoveclosures without manual field extraction.
Event Handlers
Events use the onevent: handler syntax:
#![allow(unused)]
fn main() {
// Inside a #[component] function:
let count = Signal::new(0);
rsx! {
button {
onclick: move || count.update(|n| *n += 1),
"Increment"
}
}
}
Sharing Event Handlers
You can extract event handlers into variables and reuse them across multiple elements. This is especially useful when a button and a text input should trigger the same action:
#![allow(unused)]
fn main() {
let input_text = Signal::new(String::new());
let todos = Signal::new(Vec::<String>::new());
// Extract shared logic into a closure
let add_todo = move || {
let text = input_text.get();
if !text.trim().is_empty() {
todos.update(|t| t.push(text.trim().to_string()));
input_text.set(String::new());
}
};
rsx! {
// Both TextInput (on Enter) and Button (on click) use the same handler
TextInput {
value_fn: move || input_text.get(),
oninput: move |value: String| input_text.set(value),
onsubmit: add_todo,
}
Button { onclick: add_todo, "Add" }
}
}
This works because Signal implements Copy, so closures capturing signals can be used in multiple places without .clone().
Style Shorthands
The rsx! macro supports CSS shorthand props on both HTML elements and components. Shorthands expand to set_style() calls that merge with existing styles.
Spacing scale values (xs, sm, md, lg, xl) auto-resolve to var(--rinch-spacing-{value}).
Available Shorthands
| Shorthand | CSS Property | Shorthand | CSS Property |
|---|---|---|---|
w | width | m | margin |
h | height | mt | margin-top |
miw | min-width | mb | margin-bottom |
maw | max-width | ml | margin-left |
mih | min-height | mr | margin-right |
mah | max-height | mx | margin-left + margin-right |
p | padding | my | margin-top + margin-bottom |
pt | padding-top | display | display |
pb | padding-bottom | pos | position |
pl | padding-left | top | top |
pr | padding-right | bottom | bottom |
px | padding-left + padding-right | left | left |
py | padding-top + padding-bottom | right | right |
Usage
#![allow(unused)]
fn main() {
// On HTML elements
div { p: "md", m: "lg", w: "200px", "Padded and margined" }
// On components
Stack { gap: "md", p: "xl", maw: "600px",
Text { "Constrained content" }
}
// Reactive shorthands
let big = Signal::new(false);
div { p: {|| if big.get() { "xl" } else { "sm" }}, "Dynamic padding" }
// Spacing scale values auto-resolve
div { p: "md" } // becomes: padding: var(--rinch-spacing-md)
div { p: "20px" } // becomes: padding: 20px (passed through)
}
Application Order
Shorthands are applied via set_style() after component rendering and after the style: prop. This means shorthands win over conflicting properties in style:.
Styling
Inline styles and CSS classes work like regular HTML:
#![allow(unused)]
fn main() {
rsx! {
html {
head {
style {
"
.container { padding: 20px; }
.highlight { color: red; }
"
}
}
body {
div { class: "container",
span { class: "highlight", "Styled text" }
}
}
}
}
}