Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

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:

  1. Creates DOM nodes via RenderScope
  2. Sets up Effects for reactive expressions {|| ...}
  3. 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" }
    }
}
}

Boolean attributes

A handful of HTML attributes carry no useful string: their presence is their value. disabled, checked, readonly, required, multiple, hidden, selected, open, autofocus and the rest of the HTML boolean set are all of this kind, and so are rinch’s own data-disabled, data-nofocus, data-trap-focus, data-backdrop and data-scroll-lock-exempt.

Write them as a bool — static or reactive — and rsx! renders the right shape rather than the string:

#![allow(unused)]
fn main() {
let busy = Signal::new(false);

rsx! {
    // true  → `disabled` (present, no value)
    // false → the attribute is removed
    button { disabled: {move || busy.get()}, "Rename" }
}
}

This matters because HTML has no falsey spelling: a present disabled attribute disables the control whatever it holds, so disabled="false" is still disabled. Writing the string would make the binding a one-way latch — the control could turn off and never back on (issue #551).

The rule is keyed on the attribute’s name, not on the value’s type, because several attributes that take a bool-looking value need the literal "false" kept:

#![allow(unused)]
fn main() {
rsx! {
    // `draggable` is an *enumerated* attribute: "true" / "false" are both
    // values, and removing it means `auto`. It keeps the string.
    div { draggable: {move || on.get()} }
    // So do the ARIA states, and rinch's own `data-viewport-ready` — an
    // opt-out whose *absence* means ready.
    div { aria-expanded: {move || open.get()} }
}
}

A closure that yields a string into a boolean attribute follows a writer’s truthiness rule: everything is on except "false" (any case) and "0", so the bare "" that components write stays on.

That rule is the writer’s, and for the HTML set it is nobody’s reader: no reader of disabled, checked, readonly and their kind has a falsey string on either backend, because a browser has none (issue #612). rinch’s own data-disabled, data-nofocus, data-trap-focus, data-backdrop and data-scroll-lock-exempt — listed as boolean attributes above — are the exception, and the only one: their readers do honour a literal "false", on desktop through rinch_core::dom::data_attr_is_on and, for the ones the web reads at all, through selectors like [data-nofocus]:not([data-nofocus="false" i]). That escape is narrower than the writer’s rule, too: "0" is on for those five.

Writing one by hand. set_attribute is the literal primitive on both backends: it writes the string you give it, so set_attribute("checked", "false") leaves the attribute present, and a present boolean attribute is true. There are two spellings of off, and neither is a string:

#![allow(unused)]
fn main() {
// Rendering a bool you already have — presence or absence, chosen for you.
node.write_attribute("checked", &flag.to_string());

// Or say it outright.
if flag { node.set_attribute("checked", "") } else { node.remove_attribute("checked") }
}

That was a real divergence until issue #622: the web backend mapped truthiness onto presence for checked and selected alone, so the same call unchecked a box in the browser and checked it on the desktop backend.

On the web, checked, <option selected> and a media element’s muted also drive the control’s live property, not only the attribute — a browser stops mirroring checked/selected’s attribute into the property once the user has toggled the control (and never mirrors muted’s dynamically at all; HTML reads it once, at load, to seed defaultMuted), and rinch has no such flag, so a programmatic write keeps winning. checked/selected follow the attribute’s presence, matching what the desktop backend’s :checked reads; muted is rinch’s own invention — deliberately more dynamic than plain HTML — because nothing else lets a reactive muted: {|| m.get()} binding mute or unmute a <video>/<audio> element at all (issue #754). The name is folded ASCII-lowercase before any of this is decided, so CHECKED/MUTED written in any case still mirror (issue #758) — desktop already folds the attribute name this way (#738); on the web the browser folds the written attribute itself, so only rinch’s own Rust-side matching needed the fold.

These three are also the reason a falsey write of them is never skipped. Turning a boolean attribute off is otherwise a no-op when the attribute is already gone, and write_attribute skips it to save a restyle — but on the web a user’s toggle (or, for muted, a <video controls>’s own mute button) leaves the property on with the attribute absent, so the skip was exactly the write that would have corrected it, and a box the user clicked stayed checked against a binding that said false (issue #687, and #754 for muted). checked, selected and muted now always reach the backend, which is the only layer that can see the property.

Component props are unaffected — a component’s disabled: bool is an ordinary typed field, and the component decides how to render it.

Event Handlers

Event handlers are on* attributes on HTML elements. Pointer/click handlers are Fn() closures; read the cursor position, button, and modifier keys from get_click_context():

#![allow(unused)]
fn main() {
use rinch::prelude::*;

rsx! {
    div {
        onclick: || println!("clicked"),
        onmousedown: || {
            let ctx = get_click_context();
            match ctx.button {
                MouseButton::Right => println!("right press"),
                _ if ctx.modifiers.shift => println!("shift+press"),
                _ => println!("press at {}, {}", ctx.mouse_x, ctx.mouse_y),
            }
        },
        onmouseup: || println!("released"),
        onmousemove: || { /* fires on each (coalesced) move over this element */ },
        onmouseenter: || println!("hover in"),
        onmouseleave: || println!("hover out"),
        oncontextmenu: || println!("right-click menu"),
        "Interactive"
    }
}
}

Supported HTML-element event attributes:

AttributeFiresClosure
onclickPrimary press (dispatched on pointerdown), or Enter/Space on the focused element — see Keyboard activation. A right or middle press does not run it, on either backend (both did until issue #1093); use oncontextmenu, or onmousedown with get_click_context().button. An overlay backdrop marked data-backdrop (rinch_core::events::BACKDROP_ATTRIBUTE) is the exception and runs for every button, so a press of any button outside an overlay dismisses itFn()
onmousedown / onmouseupPointer press / release (any button)Fn()
onmousemovePointer moves over the element — at most once per event-loop batch on desktop, at the newest position (see Pointer moves are coalesced)Fn()
onmouseenter / onmouseleavePointer enters / leaves the elementFn()
oncontextmenuRight-click, or a 500ms long press on Android (suppresses the native menu when handled)Fn()
oninput<input>/<textarea> value change, per keystrokeFn(String)
onchangeCommit boundary: the gesture ends (blur after a modification, Enter, a <select> pick) — fires with the final valueFn(String)
onscrollScroll container scrolls, on either axisFn(ScrollEvent) — ev.scroll_top and ev.scroll_left
ondragstart … ondrop, ondragendElement drag-and-drop. A cancelled drag (Escape; on desktop also a left press or a window blur while it is live, which prove its release went missing) fires ondragend (and ondragleave on the target, if it is over one), never ondropFn()
onfiledrop, onfiledragenter/onfiledragleaveOS → app file dropFn(Vec<PathBuf>) / Fn()

Pointer moves are coalesced

On desktop, winit can report several pointer moves between two frames — a 1000 Hz mouse does. The shell keeps only the newest one and hands it to the app before the next other event (so a press is judged where the pointer last was) or at the end of the batch. Hover, onmousemove, data-ondragover / data-ondragmove and a Drag::absolute() / Drag::percent() on_move all see one move per batch, at the latest position. This is the browser’s model: it dispatches pointermove at most once per frame. A component drag’s layout runs once per frame too, not once per move.

One pointer-capture drag at a time

Drag::start() ends any drag that is already live before arming its own, and it ends it through that drag’s on_cancel — never its on_end. Usually a press arming a drag while another is live means the earlier drag’s release was lost (a context menu or a window-manager grab swallowed it, or it went to another rinch document on the same thread — a DevTools window, a second embedded RinchContext), so there is no trustworthy position to commit. On a touch screen it can also be a second finger: rinch-web dispatches a second finger’s press, and a drag it arms cancels the first finger’s while that finger is still down — there is one drag at a time, not one per finger. Keep teardown in on_cancel and it runs on every ending but a commit — and an unmount of the component that armed the drag, which drops it without calling back.

A drag whose release never arrives is ended the same way, through on_cancel at the last position on_move saw. On rinch-web that happens on the first move that reports the button up. Desktop cannot see the button on a move, so it ends the drag on the next event that proves the release was missed: a left press (handled before the press reaches any handler, so nothing it dispatches sees the old drag) or the window losing focus. Until one of those arrives, the stranded drag keeps following the pointer. The same press or blur also ends a stranded element drag (cancelled: ondragend, no ondrop), a scrollbar-thumb drag and a text-selection drag.

Three consequences on desktop. A window blur mid-drag cancels the drag even when its release would still have arrived (on X11 and Wayland a global hotkey or a held Alt+Tab can blur the window during a healthy drag). A drag armed by the middle or right button is cancelled by a left press made while it is held. And a drag armed with no button down — from a shortcut or a timer, then placed with a click — is cancelled by that click, as the web cancels it on its first move. Arm a Drag from a press (onclick or onmousedown) and none of this reaches a normal gesture.

A drag ends on the release of the button that started it. On desktop a right or middle release while the left button still holds a drag is a chord, not the end of it: a left-armed Drag keeps following the pointer and commits on the left release, and the same holds for an element drag (it is not dropped), a scrollbar-thumb drag and a text-selection drag. A drag armed by a right press ends on the right release, and a drag armed outside any press (a timer) belongs to whichever button was pressed last. The web gets this from the browser, whose pointerup fires only once every button is up.

onscroll fires once per container that moved, whichever axis moved it, and its ScrollEvent payload carries both offsets — so a horizontal-only scroller reports its position rather than an unchanging scroll_top (issue #177). ScrollEvent is #[non_exhaustive]: read its fields by name, and construct one (in a test, say) with ScrollEvent::new(top, left) rather than a struct literal.

#![allow(unused)]
fn main() {
div {
    style: "overflow: auto; width: 200px; height: 100px",
    onscroll: move |ev: ScrollEvent| {
        column.set((ev.scroll_left / char_width) as usize);
    },
    // …wide content…
}
}

On desktop a scroll offset is kept on the device pixel grid (issue #1543), so scrolled text moves by whole device pixels and does not re-space as it scrolls: at a display scale of 125% the offsets are multiples of 0.8px. The ScrollEvent offsets and scroll_top() report that snapped value, so set_scroll_top(10.33) at 125% reads back 9.6. Do not compare an offset you wrote with one you read back for equality.

oninput and onchange follow HTML semantics and are not aliases (they were before issue #226): oninput fires on every keystroke with the live value, while onchange fires once when the typed gesture ends — focus leaves the control after a modification, Enter commits explicitly, or a <select> commits a pick — and only if the value actually changed since the control was focused. Use oninput for live previews and controlled inputs, onchange for validate-on-commit, autosave-on-leave, and undo bracketing. On Enter, onchange fires before onsubmit, and the eventual blur does not re-fire an already-committed value. Enter commits single-line inputs only — in a <textarea> (as in HTML) the gesture runs until blur, where Enter inserts a line break instead (see Enter in a <textarea>). Like the browser’s change, the event bubbles: data-onchange on an ancestor of the control receives the commit too.

Mouse and click handlers read per-event data from get_click_context(): mouse_x/mouse_y, element bounds (relative_x(), percent_x(), …), button (MouseButton::{Left, Middle, Right}), and modifiers (shift/ctrl/alt/meta). These behave identically on the desktop and web (WASM) backends.

The element bounds are the box the element is painted in, which is what getBoundingClientRect() reports on the web — so a CSS transform on the element or any ancestor moves and resizes them, and a position: fixed element reports its viewport box rather than one displaced by its ancestors (#203). That is what makes Drag::percent() and percent_x() read correctly inside the left: 50%; top: 50%; transform: translate(-50%, -50%) centring idiom, and inside a transform: scale() zoom container. mouse_x/mouse_y are unchanged — still plain viewport coordinates, which is what popup placement in DropdownMenu, Select and ContextMenu reads them as. For a rotated element the bounds are the axis-aligned bounding box, the same approximation the browser makes.

Keyboard activation

Enter and Space on a keyboard-focused element run its onclick — the same handler as a pointer press, on both backends. What Tab can reach differs by backend: on web the browser makes <button>, <a href> and the like Tab stops on its own; on desktop only an element with tabindex (or a text input) is a Tab stop today, so a bare <button> needs tabindex="0" there (issue #252). The handler receives a ClickContext whose cursor sits at the element’s centre (mouse_x/mouse_y = the middle of element_x/element_y

  • size), with button: Left and no text hit, so placement logic that reads get_click_context() (a Select flipping to fit the viewport, say) keeps working for a keyboard user. Those bounds are the painted box, the same one a pointer press reports, so a transformed element activates to the same numbers either way in. Ctrl/Meta chords are left alone; Shift and Alt ride along in modifiers.

On desktop, activation latches once per physical press (a held key does not repeat). On web, two routes cover the two kinds of element:

  • Natively activatable elements — <button> and <summary> on Enter or Space, <a href> on Enter, checkbox/radio inputs on Space — use the browser’s own activation: the click the browser synthesises is what dispatches the handler. The browser’s semantics apply, so <a href> also navigates on Enter (exactly as a mouse click on it already does), a checkbox still toggles, and a held Enter repeats at the browser’s key-repeat rate. Where the browser has no activation for a key — Space on a link — rinch dispatches it from keydown instead (the handler runs, the page does not scroll, and nothing navigates), matching desktop.
  • tabindex elements (a <div tabindex="0"> such as a Tree node) get no browser click, so rinch dispatches from keydown: once per press, and the key is consumed for the whole press — including its auto-repeats — so a held Space neither re-activates nor scrolls the page. Elements the browser focuses on its own without tabindex (a keyboard-focusable scroll container, <video controls>) keep their keys: Space there scrolls or plays, it does not activate the clickable around them.

Neither route double-fires with the mouse: a pointer press dispatches from pointerdown, and the trailing click of that same gesture — including the extra click a <label> fires at its control — is suppressed, even if a key was pressed while the button was held. A press that dispatched nothing has nothing to duplicate, so its clicks are not suppressed: a <label for="x"> outside every handler, whose <input id="x"> sits inside a clickable wrapper, activates that wrapper once by mouse as it does by keyboard (issue #272, web only — the desktop backend does not follow for, so there the press does nothing). The same rule lets a click through when something else swallowed the press: a third-party widget (a map, a chart) inside a clickable card that stops its own pointerdown from propagating now activates the card on release, as a browser’s own click would — stop click too if the widget must not. Clicks with no pointer gesture behind them — assistive technology (whose clicks are trusted, with no pointer or key event of their own on Firefox/WebKit), element.click() — are honoured once, and a click a handler raises itself (hidden_input.click()) does not re-enter that handler. Enter inside an <input>/<textarea>, an editable region or the rich-text editor is never an activation of a surrounding clickable; it is the onsubmit gesture described above (or, in a <textarea> with no onsubmit, a line break). An element with no live handler in its ancestry is a quiet no-op — the key falls through to the browser, so Tab and scrolling keep their usual meaning.

A mouse press focuses the nearest focusable ancestor of what it hits, like a browser — so a clicked tabindex element owns Enter/Space straight away, without having to be reached by Tab first. A component that wants the keys for itself (a code editor, a keyboard-driven grid) registers for them; see Keyboard Focus.

Enter in a <textarea>

Enter has two meanings in a multi-line field, and which one it takes is the author’s declaration:

FieldKeyWhat happens
<textarea>Enter, no onsubmitinserts a line break
<textarea>Enter, with onsubmitruns onsubmit — nothing is inserted
<textarea>Shift+Enteralways inserts a line break
<input>Enter / Shift+Entercommits (onchange, then onsubmit); never inserts

So a notes field takes line breaks with no ceremony, a chat composer sends on Enter and takes a break on Shift+Enter, and a single-line <input> is unchanged — a line break is not representable in its value.

onsubmit is read the way onchange is: from the control, then up its ancestors, so an onsubmit on the wrapper around a field submits that field. One backend difference to know about: on web, Shift+Enter in an <input> does nothing (the browser’s own Enter path is what desktop’s commit stands in for), while on desktop it commits like a plain Enter.

The break is an ordinary edit: it moves the caret, fires oninput with the new value (\n included) and is one Backspace away from gone. On Android the soft keyboard is told per focused field which kind it is serving, so a <textarea> gets a keyboard whose Enter types a newline instead of an action key that ends the input session.

Moving between lines

ArrowUp and ArrowDown move the caret one visual line in a <textarea> — a line the text soft-wrapped onto counts, as in a browser — aiming at the column the first of a run of vertical moves started from, so a short line in between does not pull the caret left for good. On desktop this reads the same text layout a click is hit-tested against, so the caret lands where a click at that height would put it (except at a soft-wrap point, below). ArrowUp on the first line goes to the start of the text and ArrowDown on the last line to its end; in a single-line <input> that is all they do (Chrome’s behaviour on Linux and Windows). Shift extends the selection. Without Shift a selection collapses, moving from its start (up) or its end (down). An <input type="number"> is not moved: a browser steps its value there, which desktop does not do.

Where a soft-wrapped line is shorter than the column aimed at, desktop stops one character before the wrap point, since a caret at the wrap point itself would be drawn at the start of the next line. A browser keeps a caret affinity for that and places it after the last character instead. Where the wrap is at a space the space hangs and nothing looks different; in text with no spaces (CJK) the caret is visibly one column short.

Home and End in a <textarea> go to the start and end of the caret’s visual line, soft-wrapped lines included; Ctrl+Home and Ctrl+End (Cmd on macOS) go to the start and end of the whole text. Shift extends the selection. Without Shift a selection collapses and moves from its head — the end the caret is at — rather than from its start or end as ArrowUp and ArrowDown do, which is what Chrome does. In a single-line <input> Home and End go to the ends of the value. A Home or End moves the caret off the column a run of vertical moves was aiming at, so the next ArrowUp or ArrowDown aims from where the caret now is; one that leaves the caret where it was keeps that column, as in Chrome.

End on a soft-wrapped line follows the rule above: desktop stops one character before the wrap point, a browser at the wrap point with the caret drawn at the end of the line. When the wrap is at a space, the caret sits before that space, so a character typed there goes before it — abcd abcdX abcd where Chrome types abcd abcd Xabcd.

Sizing a <textarea>

A <textarea> holds its value in its value attribute, and has no content to size against. Text written as its child — textarea { "hi" }, or a parsed <textarea>hi</textarea> — is its default value, as in HTML: the field shows and edits it, and follows it when it changes (a reactive textarea { {|| draft.get()} }) — until the user edits the field or something sets its value. That is HTML’s dirty value flag, which focusing and blurring do not set (#1186); from then on a child change updates only the default value (defaultValue on the web), and the field keeps what the user typed. The rule is the same on the desktop and on rinch-web, value writes included (#1222): a write equal to the shown text sets the flag too, as a script .value write does in Chrome, and removing the value attribute is a write of "" — the field empties and stays dirty, since no script write clears the flag again. (A browser’s form reset does; the desktop has no form reset.) A child change while the field is focused and unedited keeps the caret at its offset, clamped to the new text, as in Chrome.

A text child is not a controlled value. Until #1206, rinch-web wrote a textarea’s value from its children on every change (#100), so textarea { {|| draft.get()} } with draft.set(String::new()) after a submit cleared the field there even after the user had typed — and did not on the desktop. It no longer does on either backend. For a field the app keeps in step with a signal, bind the value itself — a value_fn on the Textarea component, or a reactive value: on the raw element — which reaches the field whether or not the user has typed:

#![allow(unused)]
fn main() {
let draft = Signal::new(String::new());
rsx! {
    div {
        Textarea {
            value_fn: move || draft.get(),
            oninput: move |v: String| draft.set(v),
        }
        // or, on the raw element:
        // textarea { value: {|| draft.get()}, oninput: move |v: String| draft.set(v) }
        button { onclick: move || { send(draft.get()); draft.set(String::new()); }, "Send" }
    }
}
}

It sizes the field no more than a value does. An <input>’s children are not shown at all, and neither is an element child of a <textarea> — a div built into one through rsx or append_child has no box, draws nothing and does not size the field (#1178), as in a browser. A control with no width is as wide as its cols (a textarea, default 20) or size (a text <input>, default 20) average characters of its font, as in a browser, and a textarea reserves a scrollbar’s width beside them (#1177). A textarea’s height comes from rows, which reserves that many lines plus padding and border — 2 rows when unset, matching HTML. rows is read as HTML reads it: the leading digits ("3abc" is 3, "2.5" is 2), and 2 when there are none or they come to zero:

#![allow(unused)]
fn main() {
rsx! {
    textarea { rows: "6" }                        // six lines tall
    textarea { style: "min-height: 200px;" }      // or size it with CSS
}
}

min-height, max-height and an explicit height all work as usual: height overrides the rows height outright, min-height acts as a floor so the taller of the two wins, and max-height caps it. The rows height is the textarea’s content height, as in a browser, so a min-height: 0 textarea in a flex column can shrink below it. An <input> is sized the same way from one line. The Textarea component exposes the same thing as min_rows.

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: TablerIcon::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.

Attributes on a component

A component’s props are the fields of its struct, so Group { gap: "xs" } sets a field. A hyphenated name can never be a Rust field, so rsx! treats one written on a component as an attribute for the component’s root DOM element, applied after Component::render — the same route the universal style: and class: props take:

#![allow(unused)]
fn main() {
rsx! {
    // The whole toolbar takes clicks without taking the keyboard; no wrapper div.
    Group { gap: "xs", data-nofocus: "", aria-label: "Formatting",
        Button { onclick: move || ed.command("toggleBold"), "B" }
    }
}
}

That covers data-*, aria-* and any other hyphenated name (issue #433). The rules are the HTML element’s rules:

  • A boolean attribute — the HTML set and rinch’s own data-disabled, data-nofocus, data-trap-focus, data-backdrop and data-scroll-lock-exempt — is written as a presence for a truthy value and removed for a falsey one (Boolean attributes). Any other attribute is written as the string.
  • A reactive value, data-state: {|| state.get()}, is an effect on the root that rewrites the attribute when its signals change, and re-renders nothing — on a component that re-renders for a reactive struct prop too (issue #1190): the effect belongs to the current render, so a change to the attribute’s signals updates the root in place and the component keeps its local state. When the component re-renders for its struct prop, every attribute is written again on the new root, and a reactive one gets a fresh effect there. The same holds for style:, class: and the style shorthands.
  • The caller writes last. An attribute the component also writes on its own root takes the caller’s value at mount, and a falsey boolean removes the component’s. After that the last write wins: a component effect that later writes the same attribute overwrites the caller’s value, and a reactive attribute overwrites it back only when its own signals change. That includes rinch’s own wiring attributes: a caller’s data-rid on a component replaces the id of the component’s own click handler, so it stops firing. Don’t.

Unhyphenated attribute names — id, tabindex, role — are not routed: on a component they are still struct fields, and a component that does not declare one does not compile.

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 state
  • hashmap.get("key") - reading a regular value

The closure {|| ...} explicitly marks the expression as reactive, telling Rinch to:

  1. Create an Effect that wraps this expression
  2. Track which signals are read inside the closure
  3. 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?

ContextFine-Grained?Notes
Text content✅ Yes`{
style attribute✅ YesUpdates specific element’s style
class attribute✅ YesUpdates specific element’s class
Portal content✅ YesContent inside portals is reactive
if/else blocks✅ YesNative reactive conditional rendering
for loops✅ YesNative reactive list rendering with keyed reconciliation
match blocks✅ YesNative reactive multi-branch rendering
.iter().map()✅ YesCreates a display:contents wrapper for the Vec
Window/MenuN/ANative 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" }
        }
    }
}
}

Showing a subtree you built earlier

A branch body does not have to build fresh markup. It may yield a NodeHandle you built once outside the if, and toggling the condition then moves that same subtree in and out of the page rather than rebuilding it:

#![allow(unused)]
fn main() {
let panel = heavy_panel(__scope);   // built once
let open = Signal::new(false);

rsx! {
    div {
        if open.get() {
            {panel.clone()}
        }
    }
}
}

The subtree keeps its identity across every toggle: its DOM nodes, any state inside them, and anything else holding a handle into it all survive being hidden. That is guaranteed on both backends — issue #719 was a period during which rinch-web lost the subtree on the first hide, silently, while desktop kept it.

The subtree is kept wherever it sits in the branch’s markup, including nested inside something the branch builds itself — if open.get() { div { {panel.clone()} } } — which used to throw it away with the wrapper (issue #732). The wrapper div is the branch’s own and is rebuilt on every show and discarded on every hide as usual; panel is detached rather than discarded along with it, and the next show puts the same panel back inside a fresh wrapper.

What counts as “built by the branch” is anything minted through the branch’s RenderScope or through a scope created on its behalf — a for’s rows, a nested if, a component, a List patching a late row — and anything minted by raw document access under such a node (an Editor’s blocks, raw HTML). If your own code builds nodes through a separate RenderScope that belong to the branch and go with it (an observer patching rows, a timer adding to the branch’s markup), create it with RenderScope::with_parent(doc, node, Some(scope.id())), naming the branch’s scope. Content that must outlive the branch — a cache you show again later — must not name it: build it with RenderScope::new, or outside the branch, and it is only ever detached. A RenderScope::new placed inside a branch’s markup belongs to no branch, so a hide only detaches its nodes; if nothing shows them again they are never reclaimed.

A cache filled from inside a for

An if can be handed a panel that was built before it. A for row usually cannot: the row’s subtree depends on the item, so a cache of rows is filled inside the row’s body, the first time each key is seen. Everything built through the scope the body is given belongs to the row — its nodes are discarded when the key leaves the list, and its effects, signals and event handlers are disposed — so a row cached that way is gone the next time its key appears (issue #733). On rinch-web nothing is shown for it; on desktop the nodes come back and their reactive bindings do not.

Build a cached row through a cache scope instead, and keep the scope with the row:

#![allow(unused)]
fn main() {
type TabCache = Rc<RefCell<HashMap<u32, (NodeHandle, RenderScope)>>>;

/// Hand back the cached tab, or build it once and keep its scope with it.
fn cached_tab(scope: &mut RenderScope, cache: &TabCache, id: u32) -> NodeHandle {
    if let Some((tab, _keep)) = cache.borrow().get(&id) {
        return tab.clone();
    }
    let mut keep = scope.cache_scope();
    let tab = keep.build(|__scope| rsx! {
        article { {expensive_tab(__scope, id)} }
    });
    cache.borrow_mut().insert(id, (tab.clone(), keep));
    tab
}

#[component]
fn tab_strip(open_tabs: Signal<Vec<u32>>) -> NodeHandle {
    let cache = TabCache::default();
    // Let go of every cached tab when the strip unmounts.
    let evict = cache.clone();
    __scope.on_cleanup(move || {
        for (_, (tab, keep)) in evict.borrow_mut().drain() {
            keep.dispose();
            tab.discard();
        }
    });
    rsx! {
        div {
            for id in open_tabs.get() {
                {cached_tab(__scope, &cache, id)}
            }
        }
    }
}
}

(This block is compiled and run as crates/rinch-macros/tests/rsx_for_cache_scope_733.rs.)

  • scope.cache_scope() returns a new RenderScope on the same document that belongs to no row and no branch. Nothing the list does discards what it builds.

  • keep.build(|__scope| …) builds through it and makes it the owner of every Signal::new, Memo::new, store and event handler created inside. Do not skip build and use the cache scope’s &mut directly: the row then comes back with its nodes, but a Signal::new, a memo or an onclick made while building belonged to the row that happened to be rendering and was freed with it. A write to that signal is then a no-op, the bindings that read it never update, and nothing panics or warns.

  • The scope and the node are released together, by you. They are two values, and nothing ties them:

    • drop the scope and keep the node, and the node comes back inert — its effects, signals and handlers were disposed;
    • discard the node and keep the scope, and its effects go on running against a node that no longer exists, for as long as the scope lives;
    • drop both without discarding the node — which is what happens when the cache itself is dropped, because the component that owns it unmounted — and the effects stop but every cached node stays in the backend for the life of the page. Measured on the mock and in Chrome 153: 50 mounts of a list of two cached rows leave 200 nodes behind without the on_cleanup above, and none with it.

    So evicting one entry is scope.dispose() and then node.discard(), and a cache owned by anything that can unmount drains itself in on_cleanup, as tab_strip does. A key that never comes back holds its row until then. (One value that owns both and releases them in step is issue #1454.)

  • A cached row is not rebuilt when its item changes. The body is called again for a changed item under the same key and hands the same node back, so anything in it that should follow the item has to be reactive.

The same works in an if or match body whose cache is filled lazily.

What tells you when a cached row was built the wrong way. If a body’s root node — the node the body itself evaluates to — was already discarded, rinch counts it and emits a tracing warning that names cache_scope, once per node and for at most 32 nodes per thread. That is narrower than it sounds:

  • It is a tracing event, and nothing shows it unless a subscriber is installed. The desktop shell installs one; rinch-web installs none (issue #1455), so a browser app sees it only with a console subscriber of its own (the tracing-wasm crate is one), and cargo test prints it only if the test installs one (tracing_subscriber::fmt().with_test_writer()). What a test can assert on without one is the counter, rinch_core::dom::__retired_view_returns() — (returns seen, nodes warned about) on the current thread.
  • Only a backend that reclaims discarded nodes can notice: rinch-web, and the MockDomDocument your component tests run on. Desktop reclaims nothing yet, so it shows the row and reports nothing.
  • A discarded node nested in fresh markup is not noticed. for t in tabs.get() { div { class: "tab", {cached} } } with cached built through the row’s scope loses the row in the same way — the div comes back empty — and nothing is counted or logged, because the body’s root is the fresh div (issue #1456).

So the absence of a warning proves nothing: test a cached list on the mock or in a browser, not only on desktop, and assert that the row is there.

Two more things to know. The branch’s RenderScope is disposed on every hide, so effects created inside the branch closure stop; put the reactive wiring in the same scope as the panel binding, not in the closure. And a subtree that is out of the page is not laid out, so a measurement taken while it is hidden is stale.

Plain markup in a branch — if open.get() { p { "hi" } } — is rebuilt on every show and thrown away on every hide, which is what you want for anything cheap.

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:

  1. The old branch’s scope is disposed (cleaning up nested effects)
  2. Old DOM nodes are removed
  3. New branch content is rendered with a fresh scope

Braces around control flow

A brace in child position is transparent to control flow: { match x { … } } renders the same reactive match_dom as match x { … }, and the same goes for if and for, at any depth — including a braced match arm body, _ => { if … }.

That is worth stating because the two used to differ, silently. A braced construct was parsed as a plain Rust expression and evaluated once, so it kept whichever branch was true when it mounted and never changed again — one brace apart from a fully reactive twin, with no diagnostic (issue #221).

Braced control flow whose bodies are not rsx cannot be made reactive — there is no markup for the runtime to swap — so it is a compile error now instead of a silent freeze:

#![allow(unused)]
fn main() {
// error: `match` wrapped in braces renders once and never updates
{ match selected.get() {
    Some(p) => rsx! { div { {p.name.clone()} } },
    None => rsx! { div { "—" } },
} }
}

The message names both ways out, because which one is right depends on what you meant. For reactive markup, drop the braces and write each arm as rsx rather than as a nested rsx! invocation:

#![allow(unused)]
fn main() {
match selected.get() {
    Some(p) => div { {p.name.clone()} },
    None => div { "—" },
}
}

For a reactive value, wrap the whole thing in a closure — the reactive expression form from earlier in this guide:

#![allow(unused)]
fn main() {
{|| match selected.get() {
    Some(p) => p.name.clone(),
    None => "—".to_string(),
}}
}

Everything else in braces is untouched: an expression ({ count.to_string() }), a reactive closure ({|| count.get()}) and a call ({ section(__scope) }) never begin with a control-flow keyword, so they are the expressions they always were.

Capturing the same value in more than one branch

Every closure RSX builds is a move closure — a branch, a match arm, a per-item view, a reactive binding’s effect, an event handler — and show_dom takes the then branch and the else branch, so both are constructed whichever way the condition comes out. A non-Copy value named by two of them would be moved twice.

The macro handles that for you. At each construction site it emits a shadow clone of what that site shares with a sibling site, or reaches for from outside a body that re-runs. So this compiles, and label is still yours afterwards:

#![allow(unused)]
fn main() {
let label = String::from("hello");

rsx! {
    div {
        if label.is_empty() {
            p { "empty" }
        } else {
            p { {label.clone()} }
        }
        Text { {label.clone()} }
    }
}
}

What is not cloned. A value only one site names is moved, exactly as before, so a type that isn’t Clone keeps working where it always did. A value bound inside a repeating body — a let at the top of a branch, the for item itself — is a fresh local on every run and is never cloned. Signal and Memo are Copy, so where a shadow does fall on one it is a copy, not an allocation.

What still needs a manual .clone(). The analysis is lexical: it cannot see a value hidden inside a macro (format!("{}", label) hides label from it), and it compares sites within one construct — two separate if blocks that both name one String still need one of them to clone. When rustc does report an E0382 or E0507 from inside rsx!, the fix is the ordinary one: give the site that complains its own clone.

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).

Keys must be unique within one list. The whole reconcile rests on one key naming one item and one DOM node, so a repeat used to leave a row that rendered, swallowed clicks and never updated again (issue #185). What a repeat means depends on who chose the key:

Rule
You wrote key:A repeat is a mistake in your key. The repeat is not rendered and a warning is logged — the first occurrence wins, the same rule React applies to duplicate key props.
No key:The framework fabricated the key from format!("{:?}", item), so a repeated value is not your mistake. Rinch makes the fabricated key unique by its occurrence ordinal instead: every row renders.

So for tag in ["rust", "rust", "gui"] renders three rows, and for n in vec![1, 1, 2] renders three rows. Reordering such a list still moves rows rather than rebuilding them, because the ordinal follows the value, not the position.

The one thing the fallback cannot give you is stable identity across an edit: if the first "rust" is deleted, the second one inherits its key and its DOM node. Where rows carry per-row state, give them a real key::

#![allow(unused)]
fn main() {
for (i, n) in numbers.get().into_iter().enumerate() {
    div { key: i, {n.to_string()} }
}
}

Prefer a stable per-row id where the data has one. An index key makes identity follow position, so inserting or removing anywhere but the end re-renders every row after the change — correct, but it gives up the reconciliation the keys are there for. Reach for the index only when the rows genuinely have nothing else to distinguish them.

How for works internally

The for loop desugars to for_each_dom_typed(), which:

  1. Wraps the iterator in a move || { ... } closure (making it reactive)
  2. Creates a <!-- for --> comment marker in the DOM
  3. Evaluates the collection and renders initial items
  4. Creates an Effect that watches the collection
  5. 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
  6. 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"
        }
    }
}
}

A binding counts as used if either the key: expression or the body reads it, so an item used only as a key (for id in ids { span { key: id, "row" } }) does not warn unused variable, and neither does an if let or match binding its branch reads. A binding nothing reads still warns, at your pattern. (For a for loop the check is by name, so a binding that both the key and the body merely spell — as a path segment or a struct-literal field name, say — can go unreported.)

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" },
}
}

Several nodes in one arm

An arm body in braces can hold several nodes, as an if or for body can — they are rendered together as the arm:

#![allow(unused)]
fn main() {
match tab.get() {
    0 => { h2 { "Home" } p { "Welcome back." } },
    1 => { let n = count.get(); b { {n.to_string()} } " items" },
    2 => { Badge { "new" } Badge { "beta" } },
    _ => { overview_section(__scope) },   // one expression, as always
}
}

What the braces hold is decided by how they start:

  • let always starts rsx nodes.
  • An element or component (div {, Card {), a string literal, if/for/ match, or an interpolation { … } starts rsx nodes when more nodes follow it: { {label} span { "!" } }, { if open.get() { "▾" } span { "Menu" } }.
  • An element, component or string literal on its own is that one node.
  • Anything else is a single Rust expression, as it always was: a call, a variable, a closure, and a method called on a literal or on a struct ({ "a".to_string() }, { if a { "x" } else { "y" } .len() }). So { overview_section(__scope) }, { panel.clone() } and {|| count.get().to_string()} are unchanged. Lone control flow in braces is the transparent brace described under Braces around control flow.

(Until issue #395 only a body starting with let could hold more than one node.) A mistake inside a multi-node arm is reported by the rsx parser, at the mistake, like one in any other rsx body — including one inside a leading if/for/match when another node follows it. (Finding that following node after a head that failed to parse is a token-level heuristic: a condition that itself holds braces, such as if let Foo { a } = x, can defeat it and leave you with the single-node error instead.) A lone braced if/for/match whose bodies are not rsx still gets the “renders once” error described under Braces around control flow.

One consequence: { Point { x: 1 } } in an arm is a component named Point, exactly as Point { x: 1 } unbraced is, not a Rust struct literal. A struct literal that cannot be an element — shorthand fields ({ Foo { a } }), a .. base ({ Foo { ..Default::default() } }), or one with a method called on it ({ Point { x: 1 }.into_node(__scope) }) — stays an expression, and so does a path such as geom::Point { x: 1 }, which is not an element name.

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 in move closures 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}). On a positioned element the top/right/bottom/left shorthands accept any CSS length — px, %, em, var(), calc() — not just the spacing scale. A calc() mixing % with a length (calc(50% - 10px)) resolves correctly on desktop too (#278) — for insets, sizes, margins, padding, gaps, transforms and border radii alike. One named limitation: a non-affine calc() (a min()/max()/clamp() with a percentage inside) resolves as its large-container linearization on desktop rather than tracking the breakpoint.

Available Shorthands

ShorthandCSS PropertyShorthandCSS Property
wwidthmmargin
hheightmtmargin-top
miwmin-widthmbmargin-bottom
mawmax-widthmlmargin-left
mihmin-heightmrmargin-right
mahmax-heightmxmargin-left + margin-right
ppaddingmymargin-top + margin-bottom
ptpadding-topdisplaydisplay
pbpadding-bottomposposition
plpadding-lefttoptop
prpadding-rightbottombottom
pxpadding-left + padding-rightleftleft
pypadding-top + padding-bottomrightright

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

style: is merged, not assigned (issue #647). Its declarations are laid over whatever is already on the element — a shorthand prop, something the runtime wrote through set_style(), and on a component every inline declaration the component’s own render wrote — last-wins per property:

#![allow(unused)]
fn main() {
// The component publishes `--rinch-modal-z-index: 517` on its root from
// `render`; the caller's `margin: 0` joins it rather than replacing it.
Modal { z_index: 517, style: "margin: 0" }

// `padding` survives the first signal change. It used to be erased, because
// the re-firing `style:` effect wrote the whole `style` attribute.
div { style: {|| format!("color: {}", tint.get())}, p: "md" }
}

Shorthands are applied through set_style() after component rendering and after the style: prop, so a shorthand wins a collision with style: at mount:

#![allow(unused)]
fn main() {
div { style: "padding: 0", p: "12px" }   // padding is 12px
}

A reactive style: takes that back on its first re-fire, and keeps it:

#![allow(unused)]
fn main() {
let css = Signal::new(String::from("padding: 0"));
div { style: {move || css.get()}, p: "12px" }
// padding is 12px at mount, and 0 from the first `css.set(...)` onward
}

The shorthand is applied once, at mount; nothing re-asserts it. Declaring the same property from both props on one element is the mistake — pick one. Everything else composes:

#![allow(unused)]
fn main() {
let css = Signal::new(String::from("margin: 0"));
div { style: {move || css.get()}, mt: "8px" }   // margin-top stays 8px, always
}

That second line is the shape the merge has to get right, and it is not a collision: margin-top beats margin only while it stays after it in the block, so a re-run must overwrite the closure’s margin where it stands rather than removing and re-appending it. A reactive style: also never accumulates — a property the closure has stopped naming is removed, or restored to the value it had been overriding — and it leaves alone any property whose current value somebody else has written since, rather than reverting a value it never saw.

An element with no second author on its style attribute keeps the author’s string verbatim, on every fire.

One parser, and what it does with an awkward value

Every author of an inline style — a style: prop, a style shorthand, a set_style() from a component or the runtime — splits and re-joins the attribute with the same parser (issue #670). Four things follow that you can rely on:

  • A ; or : inside url(…), "…" or '…' is part of that value. A background-image: url(data:image/png;base64,…) already on the element survives every later write by anybody else. rinch-dom used to split on a bare ;, so the next unrelated set_style() on that node cut the URI in half and the image vanished.
  • A property name is ASCII case-insensitive, as CSS makes it (issue #711). style: "color: blue; COLOR: red" is one property declared twice, not two properties, and a later set_style("color", …) replaces it however either was spelled. A custom property is the exception and is compared exactly: --Foo and --foo are two variables, here and in a browser.
  • A property declared twice collapses at the last declaration’s position, which is what a browser does. That is behaviour and not spelling as soon as a shorthand is involved: style: "inset: 0px; left: 25px; inset: 4px" computes a left of 4px, because the surviving inset sits after the left it overrides.
  • …but the value that survives is the !important one, which is not always the last. style: "color: red !important; color: blue" stays red, as in a browser. The slot still moves to the last declaration’s position; only the value is decided by priority.

One caveat on the last two, and it only bites on invalid CSS (issue #722): rinch collapses duplicates before validity where a browser collapses after. A browser throws an invalid declaration away as it parses, so the duplicate never competes; rinch keeps it, and the property can end up with no usable value. style: "color: notacolor !important; color: blue" computes blue in a browser and falls back to black here. Two valid declarations — the only shape worth writing — behave identically.

What the attribute reads back as afterwards is the CSSOM-touched form: names lowercased, duplicates collapsed. A browser keeps the author’s bytes in getAttribute("style") until something touches the CSSOM and rinch re-serialises on every merge, so rinch reaches that form one write earlier — the two agree on the result, not on when it appears. An element nobody has written to since is untouched either way.

/* … */ comments are removed before any of this — from a property name as readily as a value — everywhere CSS sees one, and each leaves a space behind, because a comment ends the token before it even though it is not one itself. The two places CSS does not see a comment are inside a string and inside an unquoted url(…), which is a single token: url(http://a/*b*/c.png) keeps its /*b*/ and names the image the author wrote, as in a browser. There is one place a browser sees a comment and keeps it where rinch removes it — the interior of a custom property value, where Chrome reads --x: 1px /* c */ 2px back verbatim and rinch reads it back as 1px 2px. The token stream is the same either way, so a var() substitution cannot tell; only the attribute string differs. An !important is carried through the value untouched; it is read, never rewritten, and the reading honours CSS’s own looseness — red!important, red ! IMPORTANT and red !ImPoRtAnT are all important, while a !important inside a string or url(…), or one whose ! is escaped, is ordinary text.

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" }
            }
        }
    }
}
}