I'm learning Svelte after having used Vue for a while, but am a bit confused by some strange reactivity issues with Svelte.
I have this simple code:
Svelte Snippet
<script>
let count = 0
$: tripleCount = count * 3
const increaseCount = () => count
$: if (tripleCount > 6) count = 0
</script>
<button on:click={increaseCount}>
Clicked {count} {count === 1 ? 'time' : 'times'}
</button>
<p>
Triple count is: {tripleCount}
</p>
But, when I try to run it on Svelte's playground, I get an error message alerting me that there is a Cyclical dependency detected: tripleCount → count → tripleCount
.
I've found a few ways to fix that issue and get the Svelte component to work as intended. But, I'm curious about why I get that issue, given that there isn't any logical loop that'd be impossible to close. The equivalent code in Vue works perfectly fine.
Vue Equivalent Snippet
<script setup>
import { computed, ref, watchEffect } from 'vue'
const count = ref(0)
const tripleCount = computed(() => count.value * 3)
const incrementCount = () => count.value
watchEffect(() => {
if (tripleCount.value > 6) count.value = 0
})
</script>
<template>
<button @click="incrementCount">
Clicked {{ count }} {{ count === 1 ? 'time' : 'times' }}
</button>
<p>Triple count is: {{ tripleCount }}</p>
</template>
Live Demo of Expected Behavior
You can ignore the code in the snippet below since it's not too relevant to my question and is already described more simply above.
Just run the snippet below to see what I'm trying to get my Svelte component to do.
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<div id="app"></div>
<script>
const { createApp, ref, computed, watchEffect } = Vue
createApp({
setup() {
const count = ref(0)
const tripleCount = computed(() => count.value * 3)
const incrementCount = () => count.value
watchEffect(() => {
if (tripleCount.value > 6) count.value = 0
})
return {
count,
tripleCount,
incrementCount,
}
},
template: `
<button @click="incrementCount">
Clicked {{ count }} {{ count === 1 ? 'time' : 'times' }}
</button>
<p>Triple count is: {{ tripleCount }}</p>
`,
}).mount('#app')
</script>
Possible Solutions and New Issues
Approach A: Indirect Update
An interesting workaround to avoid getting the Cyclical dependency
error is to change count indirectly, through an intermediary resetCount
function.
<script>
// $: if (tripleCount > 6) count = 0
const resetCount = () => count = 0
$: if ($tripleCount > 6) resetCount()
</script>
Logically, this implementation is no different from the original, but somehow the Cyclical dependency
error goes away.
However, some new unexpected behavior arises. When tripleCount
is greater than 6
and count
is therefore reset to 0
, tripleCount
does not update. It retains its last value (9
in this case), and its reactivity doesn't reactivate until the next click of the button.
Why does tripleCount
not react to the change of count
when count
is reset?
Approach B: Indirect Update Delay
If I add a delay before the reset helper sets count to 0, the code will work as its Vue equivalent, with the correct behavior.
<script>
const resetCount = () => {
setTimeout(() => count = 0, 0) // zero ms delay
}
$: if ($tripleCount > 6) resetCount()
</script>
Why does this delay help tripleCount
react to the change in count
? I guess using setTimeout
is helping Svelte exit that event loop and only then handle the reactivity of the change. But, it seems quite error-prone to me that I have to be mindful of not forgetting to add these delays for a supposedly computed value to be able to pick up on all the changes of the value it's dependent on.
Is there a better way of making sure tripleCount
reacts to the resetting of count
?
As can be seen in the Vue implementation, I didn't need to use any indirect resetCount
auxiliary function nor setTimeout
hack to get the expected behavior.
Approach C: Stores
After further experimentation, I managed to get my Svelte component to work as intended by using stores like this:
<script>
import { writable, derived } from 'svelte/store';
const count = writable(0);
const tripleCount = derived(count, $count => $count * 3);
function incrementCount() {
count.update(n => n 1);
}
$: if ($tripleCount > 6) {
count.set(0);
}
</script>
<button on:click={incrementCount}>
Clicked {$count} {$count === 1 ? 'time' : 'times'}
</button>
<p>Triple count is: {$tripleCount}</p>
Would this be the recommended way to work with reactivity in Svelte? I am a bit sad because it takes away much of the simplicity that made me want to learn Svelte. I know all frameworks rely on stores for state management at some point, but it seems overkill that I need to use them to implement logic as basic as the one I was trying to implement.
I'd be extremely grateful for any guidance or feedback on the issues I presented here. Thank you so much for reading and for any help <3! Hoping I can understand how reactivity in Svelte works better ^^
PS: Summary of Questions
- Why does the intuitive way (
$: if (tripleCount > 6) count = 0
) not work? - Why does the indirect reset trick (
$: if (tripleCount > 6) resetCount()
avoid theCyclical dependency
error? - Why does the immediate delay trick (
setTimeout(() => count = 0, 0)
) ensuretripleCount
does update aftercount
is reset? - Is there a way to get the expected behavior in Svelte without an auxiliary
resetCount
function, nor asetTimeout
hack, nor by resorting to using Svelte Stores yet?
CodePudding user response:
If at all possible write it like this 4️⃣:
<script>
let count = 0
$: tripleCount = count * 3
const increaseCount = () => {
count ;
if (tripleCount > 6) count = 0
}
</script>
<button on:click={increaseCount}>
Clicked {count} {count === 1 ? 'time' : 'times'}
</button>
<p>
Triple count is: {tripleCount}
</p>
Using the event to change the data and let Svelte update the view based on that new state is the most predicable and performant way.
But maybe you don't have an event, and can only react to a change in data.
How svelte works 1️⃣
Every assignment in a reactive block is augmented by the compiler with an invalide()
call.
This invalidate marks the variable as dirty and schedules an update unless there is already an update planned.
The value of the variables are updated directly, they are regular javascript variables.
After your code ran, then the update starts.
explained in pseudo code
$: tripleCount = count * 3
<div>{count === 1 ? 'time' : 'times'}</div>
becomes
function updateData(changes) {
if (hasCountChanged(changes)) {
tripleCount = count * 3
}
}
function updateView(changes) {
if (hasTripleCountChanged(changes)) {
updateTheText(count === 1 ? 'time' : 'times')
}
}
This updateData code only runs once per component.
Svelte looks at all the reactive blocks and sorts then based on their dependencies.
It a A changes B and B changes A you've got a cycle and Svelte can't sort them in the correct order. Using the resetCount()
the compiler was unable to detect this cycle 2️⃣ and generated:
function resetCount() {
count = 0
invalidate('count')
}
function updateData(changes) {
if (hasCountChanged(changes)) {
tripleCount = count * 3
}
if (hasTripleCountChanged(changes)) {
if (tripleCount > 6) {
resetCount()
}
}
}
Because the ordering is incorrect the tripleCount and count are out of sync.
When using a setTimeout the invalidate runs after the "updateData" completed and the invalidate('count') wil trigger a new update cycle and that one will update the tripleCount 3️⃣
CodePudding user response:
As Bob clearly pointed out in his answer, a single variable cannot be updated twice in a single update cycle (a tick
in Svelte lingo).
That double update requirement becomes obvious when you try to pack your update code into the smallest possible function:
function processUpdate(_count) {
tripleCount = count * 3
if (tripleCount > 6) {
count = 0
tripleCount = 0 // this is the second time tripleCount is updated
}
}
In truth, the only way around your issue is to eliminate the cyclic dependency (as pointed out by Svelte's error message, incidentally) altogether.
This means reducing the reactive statements that cause this cyclical dependency to a single reactive statement based on one of the variables.
We can do this by reusing the minimal update function stated above:
<script>
let count = 0
let tripleCount = 0
$: processUpdate(count)
const processUpdate = (_count) => {
tripleCount = count * 3
if (tripleCount > 6) {
count = 0
tripleCount = 0
}
}
const increaseCount = () => {
count ;
}
</script>
<button on:click={increaseCount}>
Clicked {count} {count === 1 ? 'time' : 'times'}
</button>
<p>
Triple count is: {tripleCount}
</p>
And this works as you would expect: REPL