From rinch
Best practices for building UIs with the Rinch GUI framework. Proactively guides correct use of rsx! macro, reactive signals, component props, and state management. USE THIS whenever writing or editing Rust code that imports rinch or uses rsx!, Signal, #[component], or rinch components.
How this skill is triggered — by the user, by Claude, or both
Slash command
/rinch:rinchThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
You are assisting a developer using **Rinch**, a reactive GUI framework for Rust. Rinch uses an `rsx!` macro (similar to JSX), reactive Signals, and a fine-grained update model. **It is NOT React** — components run once, there are no re-renders. All dynamic updates are surgical DOM mutations via Effects.
You are assisting a developer using Rinch, a reactive GUI framework for Rust. Rinch uses an rsx! macro (similar to JSX), reactive Signals, and a fine-grained update model. It is NOT React — components run once, there are no re-renders. All dynamic updates are surgical DOM mutations via Effects.
Apply these rules whenever you write or review code that uses rinch.
rsx! — NEVER build DOM imperativelyThis is the most common mistake. Do NOT use create_element(), create_text(), set_attribute(), or append_child() to build UI. These are low-level internal APIs. All UI code MUST use the rsx! macro.
If you find yourself writing scope.create_element("div") or __scope.create_text("hello"), STOP. You are doing it wrong. Use rsx! instead.
| Imperative | rsx! equivalent |
|---|---|
scope.create_element("div") | div { } |
scope.create_text("hello") | "hello" |
node.set_attribute("class", "x") | class: "x" |
parent.append_child(&child) | Nest child inside parent |
register_handler(cb) + set_attribute("data-rid", ...) | onclick: move || cb() |
for_each_dom / for_each_dom_typed | for item in collection { ... } in rsx |
show_dom | if condition { ... } in rsx |
Rinch components run once to build the DOM. There is no re-render cycle, no virtual DOM diff, no setState equivalent that rebuilds a component. Do not write code that tries to force re-renders.
Anti-patterns to avoid:
Instead, use {|| expr} closures in rsx for reactive updates — they surgically update individual DOM nodes (text, attributes, styles) without touching the rest of the tree. For conditional content, use if/match in rsx (they're automatically reactive). For lists, use for with key:.
// WRONG — rebuilding DOM on every change
Effect::new(move || {
container.clear_children();
for item in items.get() {
let div = scope.create_element("div");
// ... rebuild everything
}
});
// CORRECT — declarative, reactive, efficient
rsx! {
div {
for item in items.get() {
div { key: item.id, {item.name.clone()} }
}
}
}
{|| expr} closuresThis is the single most important rule. Without the closure wrapper, values are captured once at initial render and silently never update. It compiles, it shows the initial value, then it's frozen forever.
// BUG — captured once, never updates
p { {count.get().to_string()} }
// CORRECT — closure creates a reactive Effect
p { {|| count.get().to_string()} }
This applies to everything dynamic — text, attributes, styles, classes:
div { class: {|| if active.get() { "on" } else { "off" }} }
div { style: {|| format!("width: {}px", width.get())} }
span { {|| format!("{} items", items.get().len())} }
Self-check: Every .get() inside rsx! should be inside a {|| ...}. If it's not, it's almost certainly a bug.
The rsx! macro auto-wraps props. Manual wrapping causes double-wrapping and confusing type errors.
| You write | Macro generates |
|---|---|
onclick: move || do_thing() | Callback::new(...).into() |
oninput: move |val| handle(val) | InputCallback::new(...).into() |
value_fn: move || text.get() | Some(Rc::new(...)) |
icon: Icon::Check | Some(Icon::Check) |
variant: "filled" | String::from("filled") |
size: 42 | Some(42) |
disabled: true | true |
// WRONG — double-wrapped, confusing type errors
Button { onclick: Some(Callback::new(|| ...)) }
Alert { icon: Some(Icon::Check) }
TextInput { value_fn: Some(Rc::new(move || text.get())) }
Button { variant: Some(String::from("filled")) }
// CORRECT — the macro handles wrapping
Button { onclick: move || do_thing() }
Alert { icon: Icon::Check }
TextInput { value_fn: move || text.get() }
Button { variant: "filled" }
let count = Signal::new(0);
// WRONG — unnecessary, Signal is Copy
let count_clone = count.clone();
button { onclick: move || count_clone.update(|n| *n += 1) }
// CORRECT — just use it in multiple closures freely
button { onclick: move || count.update(|n| *n += 1) }
p { {|| count.get().to_string()} }
Props like variant, color, size, label are String. Empty string = not set. The macro converts string literals automatically.
// WRONG
Button { variant: Some("filled".into()) }
// CORRECT
Button { variant: "filled" }
rinch::prelude::*The prelude re-exports everything from rinch-components and rinch-theme. Don't add separate crate deps.
# Cargo.toml — all you need
[dependencies]
rinch = { workspace = true, features = ["desktop", "components", "theme"] }
Important: "desktop" must be listed explicitly (workspace uses default-features = false).
use rinch::prelude::*; // Includes all components, theme, signals, etc.
// DON'T do this — redundant
// use rinch_components::*;
// use rinch_theme::*;
rsx! works in ANY crate that depends on rinch-coreThe rsx! macro generates code using rinch::core:: paths. In end-user crates, rinch is the facade crate. In internal crates, add a shim to lib.rs:
extern crate self as rinch;
#[doc(hidden)]
pub mod core {
pub use rinch_core::*;
}
Do NOT fall back to imperative code because you think rsx won't work in a particular crate.
| Scope | Use |
|---|---|
| Component-local state | Signal::new() directly |
| Shared across components | create_store() / use_store() |
| Framework internals only | create_context() / use_context() |
Effect is intentionally excluded from the prelude. For reactive DOM updates, use {|| ...} in rsx. Only import Effect explicitly for syncing with external systems.
value_fn + oninputWithout value_fn, programmatic signal.set("") won't visually clear the input.
let text = Signal::new(String::new());
// INCOMPLETE — set("") won't clear the visible input
TextInput {
oninput: move |val: String| text.set(val),
}
// CORRECT — value_fn keeps DOM in sync both directions
TextInput {
value_fn: move || text.get(),
oninput: move |val: String| text.set(val),
onsubmit: move || {
process(text.get());
text.set(String::new()); // Visually clears thanks to value_fn
},
}
key: propsItems must be Clone + PartialEq + 'static. Use key: for stable DOM reconciliation.
for todo in todos.get() {
div { key: todo.id,
{todo.name.clone()}
button {
onclick: {
let id = todo.id;
move || todos.update(|t| t.retain(|t| t.id != id))
},
"Delete"
}
}
}
Items with matching keys are not re-rendered when the list changes — their existing DOM is preserved. For per-item reactivity, use per-item Signals.
oninput receives a StringOn raw <input>/<textarea> elements (not the TextInput component):
input { oninput: move |value: String| name.set(value) }
onclick takes no arguments: button { onclick: move || do_thing() }
send(), not set()set() and update() panic off the main thread.
std::thread::spawn(move || {
// WRONG — panics
// status.set("loading".into());
// CORRECT
status.send("loading".into());
status.update_send(|s| *s = "done".into());
});
Pass {|| expr} to any component prop to make it reactive:
let active = Signal::new(false);
Button {
variant: {|| if active.get() { "filled" } else { "light" }},
onclick: move || active.update(|v| *v = !*v),
"Toggle"
}
For surgical updates (no full re-render), use _fn props where available (checked_fn, value_fn).
style: and class: are additiveThey merge with the component's own styles/classes, not replace them:
Button {
variant: "filled",
style: "margin-top: 8px",
class: "my-custom-class",
"Click"
}
if, for, and match inside rsx! are automatically tracked by the reactive system:
rsx! {
div {
if visible.get() {
p { "Shown!" }
}
match tab.get() {
0 => div { "Home" },
1 => div { "About" },
_ => div { "404" },
}
}
}
No special syntax needed — conditions, iterators, and scrutinees are auto-wrapped in Effects.
use rinch::prelude::*;
#[component]
fn App() -> NodeHandle {
let count = Signal::new(0);
rsx! {
div {
p { "Count: " {|| count.get().to_string()} }
Button {
variant: "filled",
onclick: move || count.update(|n| *n += 1),
"Increment"
}
}
}
}
fn main() {
run("My App", 800, 600, app);
}
Before finishing any rinch code:
.get() in rsx is inside {|| ...} (unless intentionally one-shot)Some(), Rc::new(), Callback::new() in rsx props.clone() on Signals or Memosvalue_fn and oninputkey: on itemssend() / update_send()npx claudepluginhub joeleaver/rinch --plugin rinchProvides best practices, patterns, and conventions for Freya Rust GUI framework. Use when writing components, hooks, elements, or working on Freya projects.
Updates Claude on Dioxus 0.5+ (to 0.7.4): signals replacing use_state, RSX overhaul, server functions, asset!(), dx CLI, router, Element-as-Result. Load for recent Dioxus work.
Provides Rust state management patterns for Makepad apps: AppState with serde persistence, theme switching, Scope::with_data, and widget tree state passing. Use for UI state across sessions.