dioxus icon indicating copy to clipboard operation
dioxus copied to clipboard

Inconsistent component remounting behavior with dynamic key prop

Open nihilox opened this issue 5 months ago • 4 comments

Problem Description

The key prop on a component is expected to trigger a full remount (unmount and mount) when its value changes, thereby resetting all internal state like hooks (use_signal, use_state, etc.).

However, I've observed inconsistent behavior depending on how the component with the dynamic key is rendered. Specifically, when a component is defined inline directly within the rsx! macro, changing its key does not trigger a remount. But if the component's VNode is first created and stored in a variable, rendering that variable does correctly trigger the remount.

This behavior is confusing, undocumented, and contradicts the expected functionality of keys, making it difficult to reliably reset component state.

Steps to Reproduce

The following minimal reproducible example demonstrates the inconsistency across several scenarios.

// main.rs
use dioxus::prelude::*;

fn main() {
    launch(App);
}

// A simple component with its own internal state
#[component]
fn Counter(initial: u32) -> Element {
    let mut count = use_signal(|| initial);
    rsx! {
        div {
            p { "initial: {initial}, count: {count}" }
            button { onclick: move |_| count += 1, "Increment" }
        }
    }
}

#[component]
fn App() -> Element {
    let mut initial = use_signal(|| 0);

    // Pre-build VNodes for testing
    let counter = rsx! {Counter { initial: initial() }};
    let counter_with_key = rsx! {Counter { key: "{initial}", initial: initial() }};

    let counter_memo = use_memo(move || rsx! {Counter { initial: initial() }});
    let counter_memo_with_key =
        use_memo(move || rsx! {Counter { key: "{initial}", initial: initial() }});
    
    rsx! {
        h1 { "Key Remount Test" }
        button {
            onclick: move |_| initial += 1,
            "Increment initial (changes key)"
        }
        p { "Current initial value: {initial}"}
        hr {}

        // --- SCENARIO 1: Inline component with key ---
        h2 { "Inline component with key" }
        // OBSERVED: Hooks are NOT reset when `initial` changes.
        Counter { key: "{initial}", initial: initial()  }

        hr {}
        // --- SCENARIO 2: Inline component with key inside an `if` block ---
        h2 { "Inline component with key inside `if true`" }
        // OBSERVED: Hooks ARE reset when `initial` changes.
        if true {
            Counter { key: "{initial}", initial: initial() }
        }

        hr{}
        // --- SCENARIO 3: Render a pre-built VNode with key ---
        h2 { "Render pre-built VNode with key" }
        // OBSERVED: Hooks ARE reset when `initial` changes.
        {counter_with_key}

        hr{}
        // --- SCENARIO 4: Render a memoized VNode with key ---
        h2 { "Render memoized VNode with key" }
        // OBSERVED: Hooks ARE reset when `initial` changes.
        {counter_memo_with_key}


        // --- Controls (without key) for comparison ---
        hr{}
        h2 { "Control: Render pre-built VNode without key" }
        // OBSERVED: Hooks are NOT reset. 
        {counter}

        hr{}
        h2 { "Control: Render memoized VNode without key" }
        // OBSERVED: Hooks are NOT reset.
        {counter_memo}
    }
}

Observed Behavior

When clicking the "Increment initial" button, the initial value and thus the key change.

  1. Inline Component (Scenario 1): The Counter component's initial prop updates, but its internal count state is preserved. The hooks are not reset.
  2. Inline Component in if true (Scenario 2): The Counter component is fully remounted. Its internal count state is reset to the new initial value.
  3. Pre-built VNode (Scenario 3 & 4): The Counter component is fully remounted, and its state is reset as expected.

Beyond that, I don't have much web development experience. If I want to reset the state of a signal when props change, what's the best practice? The lifecycle of use_effect doesn't seem to meet this requirement.

nihilox avatar Sep 24 '25 12:09 nihilox

The reason those examples behave differently is the key attribute only effects rendering inside of fragments. Outside of fragments (loops) the key attribute is ignored. 2, 3, and 4 desugar to fragments under the hood while 1 does not

ealmloff avatar Sep 24 '25 13:09 ealmloff

Thanks for the explanation, that helps clarify the role of fragments and iterators.

I tried explicitly using a Fragment to see if it would enable the key to work on a single component, but neither of these approaches triggered the desired remount:

Fragment {
    Counter { key: "{initial}", initial: initial() }
}

Fragment {  key: "{initial}",
    Counter { initial: initial() }
}

The workaround is to wrap the component in an inline rsx! call:

// This works as expected and remounts the component
{  rsx! { 
    Counter { key: "{initial}", initial: initial() }
} }

This leads to my main question: What is the idiomatic or recommended pattern for resetting a component's internal state when its props change? Is the { rsx! { Component { key: "...", ... } } } pattern the intended way to force a remount for a single component?

nihilox avatar Sep 27 '25 03:09 nihilox

This leads to my main question: What is the idiomatic or recommended pattern for resetting a component's internal state when its props change? Is the { rsx! { Component { key: "...", ... } } } pattern the intended way to force a remount for a single component?

Currently yes, although this behavior is mostly a side effect of the key behavior. Keys work as I would expect for maintaining iterator state which is there main purpose, but have some odd quirks when you use them to reset state

ealmloff avatar Sep 30 '25 16:09 ealmloff

Are there other mechanisms (other than keys) to reset state?

tdomhan avatar Nov 21 '25 12:11 tdomhan