Home > Net >  Simple reactivity experiment in Svelte fails in unexpected ways. Vue equivalent doesn't
Simple reactivity experiment in Svelte fails in unexpected ways. Vue equivalent doesn't

Time:12-17

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

  1. Why does the intuitive way ($: if (tripleCount > 6) count = 0) not work?
  2. Why does the indirect reset trick ($: if (tripleCount > 6) resetCount() avoid the Cyclical dependency error?
  3. Why does the immediate delay trick (setTimeout(() => count = 0, 0)) ensure tripleCount does update after count is reset?
  4. Is there a way to get the expected behavior in Svelte without an auxiliary resetCount function, nor a setTimeout 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

  • Related