If you are migrating from Svelte 4 to Svelte 5, you have likely encountered the "Silent Failure." You update a value, your event handler logs the correct new data to the console, but the DOM remains stubbornly frozen. The $derived rune—the successor to the reactive label $: —is not recalculating.
This usually stems from two fundamental misunderstandings of the new Signal-based architecture:
- Broken Reactivity Chains: Passing raw JavaScript objects (references) into components where Svelte expects Proxies.
- The Side-Effect Fallacy: Treating
$derivedas an event hook rather than a pure mathematical derivation.
Here is how to diagnose and fix these "broken" signals.
The Hook: Why $derived Stops Listening
In Svelte 4, reactivity was compiled. Svelte looked at your code, saw foo = bar + 1, and injected code to update foo whenever bar was invalidated.
In Svelte 5, reactivity is runtime-based using Signals. $derived(fn) creates a signal that subscribes to other signals accessed during its execution. Crucially, if the property you access inside the derivation is not a Signal (a $state proxy), the derivation treats it as a static constant. It reads the value once during initialization and never runs again.
Scenario 1: The "Unwrapped" Object Reference
This is the most common architectural pitfall. You define data in a parent component or an external helper file as a plain object, pass it to a child, and expect the child to react to mutations.
The Problem Code
Consider a Parent component passing a configuration object to a Child.
<!-- Parent.svelte -->
<script>
import Child from './Child.svelte';
// ❌ BAD: This is a plain JS object, not a Rune.
let config = {
theme: 'dark',
retries: 3
};
function toggleTheme() {
// We mutate the object, but Svelte has no way to know.
config.theme = config.theme === 'dark' ? 'light' : 'dark';
console.log('Theme changed to:', config.theme); // Logs correctly!
}
</script>
<button onclick={toggleTheme}>Toggle Theme</button>
<Child {config} />
<!-- Child.svelte -->
<script>
let { config } = $props();
// ❌ FAILS: The dependency `config.theme` is not a signal.
// Svelte reads 'dark' once, and never subscribes to updates.
let statusMessage = $derived(`Current theme is ${config.theme}`);
</script>
<p>{statusMessage}</p>
When you click the button, config.theme changes in memory. However, because config was never wrapped in $state at its source, accessing .theme is a standard property access, not a signal read. The derivation graph remains empty.
The Fix: Initiate the Proxy at the Source
To fix this, the owner of the data must initiate the reactivity. Svelte 5 uses deep proxies, so wrapping the root object makes all nested properties reactive.
<!-- Parent.svelte -->
<script>
import Child from './Child.svelte';
// ✅ FIX: Wrap the object in $state to create a Proxy
let config = $state({
theme: 'dark',
retries: 3
});
function toggleTheme() {
// This mutation is now trapped by the Proxy, triggering the Signal
config.theme = config.theme === 'dark' ? 'light' : 'dark';
}
</script>
<button onclick={toggleTheme}>Toggle Theme</button>
<Child {config} />
The "Universal Reactivity" Variation (Classes)
If you are moving logic out of .svelte files into .ts classes (a pattern highly encouraged in Svelte 5), you must use Runes inside the class.
Wrong:
// store.ts
export class Cart {
items = []; // Plain array
add(item) { this.items.push(item); }
}
Right:
// store.ts
export class Cart {
// ✅ Internal property is reactive
items = $state([]);
add(item: string) {
this.items.push(item);
}
// ✅ Getters can be derived signals
get count() {
return this.items.length;
}
}
When you instantiate const cart = new Cart() in a component, accessing cart.count inside a $derived block will now correctly register a dependency.
Scenario 2: The Side-Effect Trap
In Svelte 4, we often used $: to synchronize state: $: if (data) processData(data);
In Svelte 5, $derived must be side-effect free. If you try to set one state based on another inside a derived block, you break the unidirectional data flow.
The Problem Code
<script>
let { initialData } = $props();
let count = $state(0);
let double = $state(0);
// ❌ BAD: Trying to "push" updates via mutation inside derived
// This often results in "double" being one tick behind or not updating
// if Svelte detects a potential cycle.
$derived(() => {
double = count * 2;
console.log(double); // Side effect logic
});
</script>
The Fix: Pull, Don't Push
Refactor your state to "pull" values. $derived should return a value, not set a variable.
<script>
let { initialData } = $props();
let count = $state(0);
// ✅ FIX: Pure calculation.
// Svelte manages the caching and invalidation automatically.
let double = $derived(count * 2);
// If you truly need side effects (logging, API calls), use $effect
$effect(() => {
console.log(`Count changed to ${count}, double is ${double}`);
});
</script>
Deep Dive: Why The Proxy Matters
To debug these issues effectively, you must understand what happens under the hood when you write:
let val = $derived(obj.prop);
- Execution: Svelte executes the function
() => obj.prop. - Traps: If
objis a Svelte$stateobject, it is a JavaScriptProxy. - Reflect: The
gettrap on the Proxy fires. - Registration: Svelte's global context sees that a signal (
obj.prop) was accessed while a derivation (val) was being calculated. - Edge: An edge is drawn in the dependency graph:
obj.prop->val.
If obj is a plain object, the get trap never fires. Svelte executes the function, gets the value, sees no signals were accessed, and marks the derivation as static. It will never re-run, no matter how much you mutate obj.
Conclusion
When $derived state appears stale, do not instinctively reach for forceUpdate hacks. Ask two questions:
- Is the source object a Proxy (created via
$state)? - Am I simply returning a value (good), or am I trying to mutate something else (bad)?
Svelte 5's reactivity is precise and performant, but it requires strict adherence to the rule: If it isn't a Signal, it cannot be tracked.