diff --git a/docs-src/0.6/src/reference/component_props.md b/docs-src/0.6/src/reference/component_props.md index 051eaceb9..d3b8666e9 100644 --- a/docs-src/0.6/src/reference/component_props.md +++ b/docs-src/0.6/src/reference/component_props.md @@ -124,7 +124,7 @@ In some cases, you may wish to create a component that acts as a container for s {{#include src/doc_examples/component_element_props.rs:Clickable}} ``` -Then, when rendering the component, you can pass in the output of `rsx!{...}`: +Then, when rendering the component, you can pass in the output of `rsx!{...}`: ```rust, no_run {{#include src/doc_examples/component_element_props.rs:Clickable_usage}} @@ -151,3 +151,185 @@ DemoFrame { component_children::App {} } ``` + +## Reactive Props + +In Dioxus, props are **the primary mechanism** for enabling communication between components. When passed down, they **trigger re-renders** upon changes, allowing your UI to reflect the latest application state. But what happens when props are used inside reactive systems such as `use_memo`, `use_future`, or `use_resource`? That's where **reactive props** become essential. Without making the prop reactive, those hooks **won't know** they need to update, leading to stale computations and inconsistent interfaces. Let's explore the principles of reactive props and how you can make them **predictable**, **efficient**, and **declarative**. + +### Non-Reactive by Default + +By default, primitive props like `f64`, `String`, etc., **aren't tracked** by hooks. They are copied in and won't trigger recomputation inside memoized or asynchronous logic. + +```rust, no_run +{{#include src/doc_examples/component_reactive_props.rs:Temperature}} +``` + +This works once. But if `celsius` changes from `30.0` to `32.0`, the UI updates the Celsius label but **not** the Fahrenheit conversion. The memo still uses the **stale value** `30.0`. + +### Reactive via `ReadOnlySignal` + +Dioxus offers a built-in, elegant solution: the [`ReadOnlySignal`](https://docs.rs/dioxus/latest/dioxus/prelude/struct.ReadOnlySignal.html). It's a lightweight, `Copy`able, `Clone`able, zero-cost abstraction for reactive reads. + +```rust, no_run +{{#include src/doc_examples/component_reactive_props.rs:ReactiveTemperature}} +``` + +The `use_memo` hook now **subscribes** to the signal. Whenever `celsius` changes, the memo is recalculated. + +### `use_reactive!` for Precision Control + +If you prefer to avoid signals, use [`use_reactive`](https://docs.rs/dioxus/latest/dioxus/prelude/macro.use_reactive.html) macro to **declare dependencies** manually inside hooks: + +```rust, no_run +{{#include src/doc_examples/component_reactive_props.rs:UseReactive}} +``` + +This gives you **deterministic control** over which variables should cause recomputation, useful for deeply nested computations or external state systems. + +### Gotchas and Tips + +- **Signals are both `Copy`able and `Clone`**able: You can pass `ReadOnlySignal` by value, reference, or clone it. Internally, it's a lightweight handle to a reactive state. This makes it easy to lift signals into other hooks without worrying about ownership issues. + +- **You can combine signals with the `use_reactive!` macro**: This is useful when only part of your dependencies are reactive. For example: + + ```rust, no_run + {{#include src/doc_examples/component_reactive_props.rs:UseReactiveGotcha}} + ``` + +- **Avoid using non-reactive props in `use_future` or `use_resource`**: These hooks only re-run when their dependencies change. If those dependencies are non-reactive (like `String` or `f64`), the hook won't re-execute when they update. Example of incorrect usage: + + ```rust, no_run + {{#include src/doc_examples/component_reactive_props.rs:UseFutureGotcha}} + ``` + + Correct approach using a reactive prop: + + ```rust, no_run + {{#include src/doc_examples/component_reactive_props.rs:UseFutureCorrect}} + ``` + +- **Memoized values from `use_memo` are reactive**: The result of `use_memo` is a value. If you need the result to drive other reactive state, store it in a memo: + + ```rust, no_run + // ✅ This is a reactive memo that will update when `count` changes + let doubled = use_memo(move || count() * 2); + ``` + +- **`use_signal` vs. `use_memo`**: Use `use_signal` when the value will change due to internal component state or user interaction. Use `use_memo` when deriving a value from other reactive props or signals and you want automatic recomputation. + +- **Signal updates are conditional on value changes**: If you call `.set()` on a signal with the same value it already holds, dependent effects and hooks will not re-run. If you need to force an update, consider mutating inner data if you're using something like `Signal>`. + +- **Cloning signals in every render can be inefficient**: Although cloning signals is cheap, avoid doing it repeatedly inside `rsx!` or render loops. Instead, just read the signal: + + ```rust, no_run + rsx! { + div { "Value: {count()}" } + } + ``` + +- **Closures can capture stale props if not handled properly**: If you capture a non-reactive prop in a closure inside `use_future` or `use_memo`, it won't reflect updates. Always make sure the closure references a signal or reactive value. + +- **Debugging tip**: If a memo or effect isn't updating, log values or use visual indicators in the UI to confirm whether the input values are truly changing. In most cases, this is caused by a missing reactive dependency or an incorrectly captured closure. + +Let's consider the following example + +```rust, no_run +{{#include src/doc_examples/component_reactive_props.rs:GotchasApp}} +``` + +```inject-dioxus +DemoFrame { + component_reactive_props::App {} +} +``` + +This pattern ensures that **networked async hooks** properly respond to prop changes using signals. + +## Extending Elements + +Often when building reusable components, you want them to **act like native HTML elements**, passing arbitrary attributes like `style`, `onmouseenter`, or `disabled`. Dioxus allows this through the `#[props(extends = ...)]` attribute. This lets you **extend the props** of your component with either: + +- All **global HTML attributes** +- Attributes specific to a particular HTML tag (e.g. `button`, `input`, etc.) + +Let's consider the following example: + +```rust, no_run +{{#include src/doc_examples/component_extending_elements.rs:ExtendingPanel}} +``` + +Usage: + +```rust, no_run +{{#include src/doc_examples/component_extending_elements.rs:ExtendingPanelUsage}} +``` + +```inject-dioxus +DemoFrame { + component_extending_elements::App {} +} +``` + +No need to manually forward every attribute. Simply spread `..attrs`, and your component becomes fully HTML-compatible. + +### Merging Multiple Sources + +You can extend multiple attribute domains (e.g. global + tag-specific): + +```rust, no_run +{{#include src/doc_examples/component_extending_elements.rs:ExtendingButton}} +``` + +```rust, no_run +{{#include src/doc_examples/component_extending_elements.rs:ExtendingButtonUsage}} +``` + +```inject-dioxus +DemoFrame { + component_extending_elements::ActionApp {} +} +``` + +Dioxus resolves conflicts internally. However, avoid combining elements with **incompatible attribute semantics**. + +### Caveats and Tips + +- **Attribute collision resolution**: If multiple values are passed for the same attribute (e.g. `class` appears in both props and `..attrs`), the **last occurrence wins**. This is standard HTML behavior, but it's worth watching out for when merging attributes from different sources. + +- **Cannot override named props**: The `extends` system only covers HTML attributes. You **cannot override custom or named props** declared explicitly in the component signature. These must still be handled and destructured manually. + + ```rust, no_run + #[component] + fn Card( + title: String, // explicitly required named prop + #[props(extends = GlobalAttributes)] + attrs: Vec, + ) -> Element { + rsx! { + div { + ..attrs, // forwarded HTML attributes + h2 { "{title}" }, // uses the explicit prop + p { "This is a card." } + } + } + } + ``` + +- **Avoid extending incompatible element types**: You must **only extend attribute sets that match the tag you're rendering**. Extending `input` attributes on a `div` may compile, but attributes like `type` or `value` will be meaningless or even silently dropped in the browser. + +- **Prop filtering is manual**: If you want to restrict which attributes are allowed or filter out certain ones (e.g., to enforce accessibility or prevent accidental overrides), you must do this manually inside your component logic. + +- **Attribute order matters in practice**: While RSX generally follows a "last wins" policy, if you're conditionally overriding attributes (like `class`), you may want to control placement in the tree: + + ```rust, no_run + rsx! { + div { + class: "default", + ..attrs, // if attrs contains a 'class', it will override "default" + } + } + ``` + +- **Spreading is shallow**: `..attrs` simply forwards attribute key/value pairs. If you need to merge structured data (e.g., two `style` maps), you must handle it manually. + +- **Debugging tip**: When using `..attrs`, it can be hard to trace where an unexpected attribute came from. Consider logging or printing props during development to confirm which attributes were forwarded. diff --git a/packages/docs-router/src/doc_examples/component_extending_elements.rs b/packages/docs-router/src/doc_examples/component_extending_elements.rs new file mode 100644 index 000000000..7f1e89000 --- /dev/null +++ b/packages/docs-router/src/doc_examples/component_extending_elements.rs @@ -0,0 +1,56 @@ +#![allow(non_snake_case)] +use dioxus::prelude::*; + +#[component] +pub fn App() -> Element { + // ANCHOR: ExtendingPanelUsage + rsx! { + Panel { + style: "border: 1px solid #ccc; padding: 1em;", + id: "main-panel", + class: "highlight" + } + } + // ANCHOR_END: ExtendingPanelUsage +} + +// ANCHOR: ExtendingPanel +#[component] +fn Panel(#[props(extends = GlobalAttributes)] attrs: Vec) -> Element { + rsx! { + div { + ..attrs, + "This is a panel" + } + } +} +// ANCHOR_END: ExtendingPanel + +#[component] +pub fn ActionApp() -> Element { + // ANCHOR: ExtendingButtonUsage + rsx! { + ActionButton { + // TODO: File an issue to add this callback to `ActionButtonPropsBuilder<()>` + // cause button has `onclick` event handler and should be extended + // onclick: move |_| {}, + disabled: true, + title: "Click to execute", + class: "btn" + } + } + // ANCHOR_END: ExtendingButtonUsage +} + +// ANCHOR: ExtendingButton +#[component] +fn ActionButton( + #[props(extends = GlobalAttributes, extends = button)] attrs: Vec, +) -> Element { + rsx! { + button { + ..attrs, + } + } +} +// ANCHOR_END: ExtendingButton diff --git a/packages/docs-router/src/doc_examples/component_reactive_props.rs b/packages/docs-router/src/doc_examples/component_reactive_props.rs new file mode 100644 index 000000000..01d0db676 --- /dev/null +++ b/packages/docs-router/src/doc_examples/component_reactive_props.rs @@ -0,0 +1,150 @@ +// ANCHOR: GotchasApp +#![allow(non_snake_case)] +use dioxus::prelude::*; + +#[component] +pub fn App() -> Element { + let mut amount = use_signal(|| 100.0); + let mut currency = use_signal(|| "USD".to_string()); + + rsx! { + div { + h1 { "Currency Converter" } + + div { + label { "Amount: " } + input { + r#type: "number", + value: "{amount()}", + oninput: move |evt| { + if let Ok(val) = evt.value().parse::() { + amount.set(val); + } + } + } + } + + div { + label { "Currency: " } + select { + onchange: move |evt| { + currency.set(evt.value()); + }, + option { value: "USD", selected: currency() == "USD", "USD" } + option { value: "EUR", selected: currency() == "EUR", "EUR" } + option { value: "JPY", selected: currency() == "JPY", "JPY" } + } + } + + CurrencyConverter { + amount: amount(), + currency: currency(), + } + } + } +} + +#[component] +fn CurrencyConverter(amount: ReadOnlySignal, currency: ReadOnlySignal) -> Element { + let exchange_rate = use_resource(move || { + let amount = amount(); + let currency = currency(); + async move { + let rate = fetch_rate(¤cy).await; + amount * rate + } + }); + + rsx! { + div { + match exchange_rate() { + Some(value) => rsx!("Converted: ${value:.2}"), + None => rsx!("Fetching rate..."), + } + } + } +} + +async fn fetch_rate(currency: &str) -> f64 { + // API call + match currency { + "USD" => 1.0, + "EUR" => 0.91, + _ => 1.0, + } +} +// ANCHOR_END: GotchasApp + +// ANCHOR: Temperature +#[component] +fn Temperature(celsius: f64) -> Element { + let fahrenheit = use_memo(move || (celsius * 9.0 / 5.0) + 32.0); + + rsx! { + div { + "Temperature: {celsius}°C / {fahrenheit:.2}°F" + } + } +} +// ANCHOR_END: Temperature + +// ANCHOR: ReactiveTemperature +#[component] +fn ReactiveTemperature(celsius: ReadOnlySignal) -> Element { + let fahrenheit = use_memo(move || (celsius() * 9.0 / 5.0) + 32.0); + + rsx! { + div { + "Temperature: {celsius}°C / {fahrenheit:.2}°F" + } + } +} +// ANCHOR_END: ReactiveTemperature + +// ANCHOR: UseReactive +#[component] +fn Investment(principal: f64, rate: f64) -> Element { + let doubled = use_memo(use_reactive!(|principal, rate| principal * (1.0 + rate))); + + rsx! { + div { + "Projected Balance: ${doubled:.2}" + } + } +} +// ANCHOR_END: UseReactive + +// ANCHOR: UseReactiveGotcha +#[component] +fn Report(metric: ReadOnlySignal, unit: String) -> Element { + let summary = use_memo(use_reactive!(|unit| format!("{} {}", metric(), unit))); + rsx! { div { "{summary}" } } +} +// ANCHOR_END: UseReactiveGotcha + +// ANCHOR: UseFutureGotcha +#[component] +fn Bad(query: String) -> Element { + use_future(move || { + let value = query.clone(); + async move { fetch_rate(&value).await } + }); + + rsx! {} +} +// ANCHOR_END: UseFutureGotcha + +// ANCHOR: UseFutureCorrect +#[component] +fn Good(query: ReadOnlySignal) -> Element { + let data = use_resource(move || async move { fetch_rate(&query()).await }); + rsx! { + div { + match data() { + Some(value) => rsx!("{value}"), + None => rsx!("Fetching rate..."), + } + } + } +} +// ANCHOR_END: UseFutureCorrect diff --git a/packages/docs-router/src/doc_examples/mod.rs b/packages/docs-router/src/doc_examples/mod.rs index 38dbd6c4b..d863d996c 100644 --- a/packages/docs-router/src/doc_examples/mod.rs +++ b/packages/docs-router/src/doc_examples/mod.rs @@ -66,10 +66,14 @@ pub mod building_uis_with_rsx; #[cfg(not(feature = "doc_test"))] pub mod component_children; #[cfg(not(feature = "doc_test"))] +pub mod component_extending_elements; +#[cfg(not(feature = "doc_test"))] pub mod component_lifecycle; #[cfg(not(feature = "doc_test"))] pub mod component_owned_props; #[cfg(not(feature = "doc_test"))] +pub mod component_reactive_props; +#[cfg(not(feature = "doc_test"))] pub mod components; #[cfg(not(feature = "doc_test"))] pub mod conditional_rendering; diff --git a/packages/docsite/src/components/llms.rs b/packages/docsite/src/components/llms.rs index d7b1d1b3b..4f47830ae 100644 --- a/packages/docsite/src/components/llms.rs +++ b/packages/docsite/src/components/llms.rs @@ -40,7 +40,10 @@ pub fn generate_llms_txt() { let path = route_to_path(&route); _ = std::fs::create_dir_all(&path); let path = path.join("llms.txt"); - let file = std::fs::File::options().create(true).write(true).open(&path)?; + let file = std::fs::File::options() + .create(true) + .write(true) + .open(&path)?; let mut file = std::io::BufWriter::new(file); writeln!(file, "This is the developer documentation for Dioxus from {route}.\n{content}")?; file.write_all(content.as_bytes())?;