Skip to main content

Svelte 5 Runes: Why Your Destructured Props Are Losing Reactivity

 

The Hook: The "One-Time" Render Trap

You are migrating a component from Svelte 4 to Svelte 5. You swap export let for the new $props() rune, adhering to the new syntax. It renders correctly on the first load. However, as parent components update the data, your child component stays stubborn. It refuses to update derived values, CSS classes, or logic dependent on those props.

You console log the prop, and it looks correct. But the UI is stale.

This is the most common architectural friction point in Svelte 5: treating $props() like a standard JavaScript object during initialization creates a snapshot, not a subscription.

The Why: Value Semantics vs. Signal Access

To understand why this breaks, you must understand how Svelte 5 handles reactivity "under the hood."

In Svelte 4, reactivity was component-scoped and compiler-driven. The compiler scanned for specific variable assignments.

In Svelte 5, reactivity is Signal-based (via Runes). When you invoke $props(), Svelte returns a Proxy object backed by signals.

The Mechanism of Failure

When you destructure a proxy in standard JavaScript, you trigger the "getter" for that property immediately.

// The Svelte 5 internals (simplified mental model)
const props = new Proxy({ count: new Signal(0) }, {
  get(target, prop) {
    return target[prop].value; // Accesses current value, registers dependency IF inside an effect
  }
});

// YOUR CODE
let { count } = props; // Reads value (0) immediately.
const double = count * 2; // Calculates 0 * 2 = 0.

In the component initialization phase (the <script> tag), code runs once. By destructuring into a let or const and using that variable in a standard expression, you have extracted the primitive value (e.g., 0"active") from the reactive system. You have severed the link to the signal.

While the Svelte 5 compiler is smart enough to handle reactivity if you use that destructured variable directly in the template, it cannot magically make a standard JavaScript expression (const x = y + 1) reactive just because the variable came from props.

The Fix: Using $derived and Referenced Access

There are two distinct ways to fix this, depending on your architectural preference.

Scenario: The Broken Code

Here is a component that fails to update the statusMessage when the code prop changes.

<script>
  let { code } = $props();

  // ❌ BROKEN: This runs once on initialization.
  // When 'code' changes, 'statusMessage' remains the original string.
  const statusMessage = code === 200 ? 'Success' : 'Error';
</script>

<div class="alert">
  {statusMessage}
</div>

Solution 1: Wrappers for Derived State (Recommended)

If you prefer destructuring for clean syntax, you must wrap any logic relying on those variables in the $derived rune. This tells Svelte to create a computation graph node that listens for changes to the underlying signal associated with code.

<script>
  let { code } = $props();

  // ✅ FIXED: $derived creates a reactive computation.
  // It effectively says: "Re-run this expression whenever the signal behind 'code' changes."
  let statusMessage = $derived(code === 200 ? 'Success' : 'Error');
</script>

<div class="alert">
  {statusMessage}
</div>

Solution 2: Direct Object Access (The "Prop Drilling" Approach)

Alternatively, you can skip destructuring entirely. By keeping the props object intact, you ensure that every access reads through the Proxy getter at the exact moment it is needed.

<script>
  let props = $props();

  // ✅ FIXED: Accessing props.code inside $derived ensures tracking works.
  let statusMessage = $derived(props.code === 200 ? 'Success' : 'Error');
</script>

<!-- In the template, direct access is also reactive -->
<div class="alert">
  {statusMessage} (Code: {props.code})
</div>

The Explanation: Rebuilding the Dependency Graph

Why does $derived fix the destructuring issue?

When Svelte compiles let { code } = $props(), it actually instruments the variable code. However, standard JavaScript assignments (const x = code) are synchronous and one-time.

When you use $derived(code === 200), Svelte executes the function inside the $derived rune. During that execution:

  1. It reads code.
  2. The signal backing code is accessed.
  3. The current Effect (the $derived calculation) registers itself as a dependency of the code signal.

Now, when the parent component updates the prop, the code signal notifies its subscribers. The $derived block is marked as "dirty" and re-evaluates.

Without $derived, the variable statusMessage is just a string in memory, calculated momentarily during component mount and never touched again.

Conclusion

The shift to Svelte 5 requires a mental model shift from "compiler magic assignments" to "signal dependency graphs."

  1. Top-level code runs once.
  2. Destructured primitives are snapshots unless used inside a Rune or the Template.
  3. Calculated values must use $derived to remain reactive.

Stop treating props as static configuration and start treating them as signals waiting to be observed.