svelte
svelte copied to clipboard
Svelte 5: better document breaking of universal reactivity with destructuring
Describe the bug
In a situation like this (see complete code in "Reproduction"):
<script>
import { createPressWatcher } from './pressWatcher.svelte.js';
const pressWatcher = createPressWatcher();
</script>
<button onmousedown={pressWatcher.onmousedown} onmouseup={pressWatcher.onmouseup}>
Pressed: {pressWatcher.pressed}
</button>
it is tempting to refactor the code like this:
<script>
import { createPressWatcher } from './pressWatcher.svelte.js';
const { pressed, ...events } = createPressWatcher();
</script>
<button {...events}>
Pressed: {pressed}
</button>
to reduce boilerplate.
But this breaks the app (events are working just fine, but pressed state is broken). As I understand it's because no dot-accessing is used and thus no getters are called, but this is not something a regular dev, who just copied reactivity pattern from docs and applied a common practice of destructuring, would immediately think about.
It is kinda of addressed in the docs with:
Note that we're using a get property in the returned object, so that
counter.countalways refers to the current value rather than the value at the time the createCounter function was called.
But the focus in this sentence seems to be on the fact of how we created not on the fact that we should consume this only with dot-access if we want to save reactivity. The user may start to look for a bug in "Universal reactivity module" but it's accessing code that should be changed.
Proposal Clearly state that for such universal reactivity case only dot-accessing works reactively
Reproduction
Working reactivity before destructuring
Broken reactivity after destructuring
Logs
No response
System Info
[email protected]
Severity
annoyance
Destructuring can be made reactive via $derived, by the way:
const { pressed, ...events } = $derived(createPressWatcher());
Although as a solution it feels a little cryptic I guess there could be use of documenting this too
I mean if you think of it properly, destructuring properies is a form of reading a derived value. So it makes sense why it would need be to wrapped with $derived, no? I think we can improve documentation, so feel free to put up a RR for the Svelte 5 docs as to how you can make that better.
How about:
Note that we're using a get property in the returned object and property accessors like
counter.countin the template. This way we refer to the current value rather than the value at the time the createCounter function was called.If you want to use destructuring instead of property accessors, wrap your call in $derived to save reactivity:
const { count, increment } = $derived(createCounter());
It would solve future reader's problem, but might also leave them with "Hm, but why it works this way?". I won't be able to answer that. Maybe some short explanation from the core team will be useful.
I came across the same issue trying to debounce a value:
// debounce.svelte.ts
export const debounce = <T>(getValue: () => T, waitFor: number = 1000) => {
let result = $state(getValue());
let timeout: ReturnType<typeof setTimeout>;
$effect(() => {
const newValue = getValue();
if (newValue === result) return;
clearTimeout(timeout);
timeout = setTimeout(() => {
result = newValue;
}, waitFor);
});
return {
get value() {
return result;
}
};
};
Where something like this worked:
<script lang="ts">
let search = $state("");
let debouncedSearch = debounce(() => search, 500);
</script>
<input type="text" bind:value={search}/>
<div>Debounced: {debouncedSearch.value}</div>
But a slightly more ergonomic attempt failed:
<script lang="ts">
let search = $state("");
let { value: debouncedSearch } = debounce(() => search, 500);
</script>
<input type="text" bind:value={search}/>
<div>Debounced: {debouncedSearch}</div>
Sadly in my case using $derived doesn't seem to work, because The Svelte $effect rune can only be used during component initialisation.
At this point it's a minor issue for me (alternative suggestions are still welcome), but it did take me quite a while to figure it out.
Oops, I didn't know that destructuring an object would lose reactivity. What's funny is that I use only stores with array $state, and in this case, destructuring doesn't cause reactivity loss.
Example: https://svelte-5-preview.vercel.app/#H4sIAAAAAAAAA22QP2_DIBDFv8oJVYqtWkm7-p_UtUM7VJ3iDJTgFNUBdByVIsR3rzBJ7KET8H7v3XEX2Kgm6Vi9D0zzs2Q1e7GWVYwuNj3cr5xIsoo541EkpXUClaV-0ACj14KU0SCM1ySxKCEkHWCSlEXo4MERJ1k8lc3COOJC9oeEMkRJHvWtDMApm5fKKxNHbG5irNaJufW_mZn8k_L2yEnWUJTQ9eucMNqZSW4ncyo22bUpmzVPUz528LwSOeLWevddzHSxx3yJsxDzzKkBQUiZKlerrr-BCN2y2mbQ7e6-_EG3ts9xr6kO8xGTJclXFl4_3t-2jlDpkxovBUcs755Bt1-eyGgwWkxK_HQht409fOb-7S47elaxszmqUckjqwm9jIf4B5NWMiQ7AgAA
+1 to document that fact about destructuring and losing reactivity. It was a bit confusing for me.
Oops, I didn't know that destructuring an object would lose reactivity. What's funny is that I use only stores with array $state, and in this case, destructuring doesn't cause reactivity loss.
Example: https://svelte-5-preview.vercel.app/#H4sIAAAAAAAAA22QP2_DIBDFv8oJVYqtWkm7-p_UtUM7VJ3iDJTgFNUBdByVIsR3rzBJ7KET8H7v3XEX2Kgm6Vi9D0zzs2Q1e7GWVYwuNj3cr5xIsoo541EkpXUClaV-0ACj14KU0SCM1ySxKCEkHWCSlEXo4MERJ1k8lc3COOJC9oeEMkRJHvWtDMApm5fKKxNHbG5irNaJufW_mZn8k_L2yEnWUJTQ9eucMNqZSW4ncyo22bUpmzVPUz528LwSOeLWevddzHSxx3yJsxDzzKkBQUiZKlerrr-BCN2y2mbQ7e6-_EG3ts9xr6kO8xGTJclXFl4_3t-2jlDpkxovBUcs755Bt1-eyGgwWkxK_HQht409fOb-7S47elaxszmqUckjqwm9jIf4B5NWMiQ7AgAA
+1 to document that fact about destructuring and losing reactivity. It was a bit confusing for me.
That’s just how JS works. When you destructure you read the values at that point and assign them to variables. If the value is an object then you get a reference - which means you keep reactivity if that object is reactive.