Svelte Reactivity Gotchas + Solutions (If you're using Svelte in production you should read this)

isaachagoel

Isaac Hagoel

Posted on October 5, 2021

Svelte Reactivity Gotchas + Solutions (If you're using Svelte in production you should read this)

Svelte is a great framework and my team has been using it to build production apps for more than a year now with great success, productivity and enjoyment. One of its core features is reactivity as a first class citizen, which is dead-simple to use and allows for some of the most expressive, declarative code imaginable: When some condition is met or something relevant has changed no matter why or how, some piece of code runs. It is freaking awesome and beautiful. Compiler magic.

When you're just playing around with it, it seems to work in a frictionless manner, but as your apps become more complex and demanding you might encounter all sorts of puzzling, undocumented behaviours that are very hard to debug.
Hopefully this short post will help alleviate some of the confusion and get back on track.

Before we start, two disclaimers:

  1. All of the examples below are contrived. Please don't bother with comments like "you could have implemented the example in some other way to avoid the issue". I know. I promise to you that we've hit every single one of these issues in real codebases, and that when a Svelte codebase is quite big and complex, these situations and misunderstandings can and do arise.
  2. I don't take credit for any of the insights presented below. They are a result of working through the issues with my team members as well as some members of the Svelte community.

Gotcha #1: Implicit dependencies are evil

This is a classic one. Let's say you write the following code:

<script>
    let a = 4;
    let b = 9;
    let sum;
    function sendSumToServer() {
        console.log("sending", sum);
    }
    $: {
        sum = a + b;
        sendSumToServer();
    }
</script>
<label>a: <input type="number" bind:value={a}></label> 
<label>b: <input type="number" bind:value={b}></label> 
<p>{sum}</p>
Enter fullscreen mode Exit fullscreen mode

It all works (click to the REPL link above or here) but then in code review you are told to extract a function to calculate the sum for "readability" or whatever other reason.
You do it and get:

<script>
    let a = 4;
    let b = 9;
    let sum;
    function calcSum() {
        sum = a + b;
    }
    function sendSumToServer() {
        console.log("sending", sum);
    }
    $: {
        calcSum();
        sendSumToServer();
    }
</script>
<label>a: <input type="number" bind:value={a}></label> 
<label>b: <input type="number" bind:value={b}></label> 
<p>{sum}</p>

Enter fullscreen mode Exit fullscreen mode

The reviewer is happy but oh no, the code doesn't work anymore. Updating a or b doesn't update the sum and doesn't report to the server. Why?
Well, the reactive block fails to realise that a and b are dependencies. Can you blame it? Not really I guess, but that doesn't help you when you have a big reactive block with multiple implicit, potentially subtle dependencies and you happened to refactor one of them out.

And it can get much worse...
Once the automatic dependency recognition mechanism misses a dependency, it loses its ability to run the reactive blocks in the expected order (a.k.a dependencies graph). Instead it runs them from top to bottom.

This code yields the expected output because Svelte keeps track of the dependencies but this version doesn't because there are hidden dependencies like we saw before and the reactive blocks ran in order. The thing is that if you happened to have the same "bad code" but in a different order like this, it would still yield the correct result, like a landmine waiting to be stepped on.
The implications of this are massive. You could have "bad code" that happens to work because all of the reactive blocks are in the "right" order by pure chance, but if you copy-paste a block to a different location in the file (while refactoring for example), suddenly everything breaks on you and you have no idea why.

It is worth restating that the issues might look obvious in these examples, but if a reactive block has a bunch of implicit dependencies and it loses track of just one on of them, it will be way less obvious.

In fact, when a reactive block has implicit dependencies the only way to understand what the dependencies actually are is to read it very carefully in its entirety (even if it is long and branching).
This makes implicit dependencies evil in a production setting.

Solution A - functions with explicit arguments list:

When calling functions from reactive blocks or when refactoring, only use functions that take all of their dependencies explicitly as arguments, so that the reactive block "sees" the parameters being passed in and "understands" that the block needs to rerun when they change - like this.

<script>
    let a = 4;
    let b = 9;
    let sum;
    function calcSum(a,b) {
        sum = a + b;
    }
    function sendSumToServer(sum) {
        console.log("sending", sum);
    }
    $: {
        calcSum(a,b);
        sendSumToServer(sum);
    }
</script>
<label>a: <input type="number" bind:value={a}></label> 
<label>b: <input type="number" bind:value={b}></label> 
<p>{sum}</p>
Enter fullscreen mode Exit fullscreen mode

I can almost hear some of you readers who are functional programmers saying "duh", still I would go for solution B (below) in most cases because even if your functions are more pure you'll need to read the entire reactive block to understand what the dependencies are.

Solution B - be explicit:

Make all of your dependencies explicit at the top of the block. I usually use an if statement with all of the dependencies at the top. Like this:

<script>
    let a = 4;
    let b = 9;
    let sum;
    function calcSum() {
        sum = a + b;
    }
    function sendSumToServer() {
        console.log("sending", sum);
    }
    $: if (!isNaN(a) && !isNaN(b)) {
        calcSum();
        sendSumToServer();
    }
</script>
<label>a: <input type="number" bind:value={a}></label> 
<label>b: <input type="number" bind:value={b}></label> 
<p>{sum}</p>
Enter fullscreen mode Exit fullscreen mode

I am not trying to say that you should write code like this when calculating the sum of two numbers. The point I am trying to make is that in the general case, such a condition at the top makes the block more readable and also immune to refactoring. It does require some discipline (to not omit any of the dependencies) but from experience it is not hard to get right when writing or changing the code.

Gotcha #2: Primitive vs. object based triggers don't behave the same

This is not unique to Svelte but Svelte makes it less obvious imho.
Consider this

<script>
    let isForRealz = false;
    let isForRealzObj = {value: false};
    function makeTrue() {
        isForRealz = true;
        isForRealzObj.value = true;
    }
    $: if (isForRealz) console.log(Date.now(), "isForRealz became true");
    $: if (isForRealzObj.value) console.log(Date.now(), "isForRealzObj became true");

</script>

<p>
    click the button multiple times, why does the second console keep firing?
</p>
<h4>isForRealz: {isForRealz && isForRealzObj.value}</h4>
<button on:click={makeTrue}>click and watch the console</button>
Enter fullscreen mode Exit fullscreen mode

If you keep clicking the button while observing the console, you would notice that the if statement behaves differently for a primitive and for an object. Which behaviour is more correct? It depends on your use case I guess but if you refactor from one to the other get ready for a surprise.
For primitives it compares by value, and won't run again as long as the value didn't change.

For objects you would be tempted to think that it is a new object every time and Svelte simply compares by reference, but that doesn't seem to apply here because when we assign using isForRealzObj.value = true; we are not creating a new object but updating the existing one, and the reference stays the same.

Solution:

Well, just keep it in mind and be careful. This one is not that hard to watch for if you are aware of it. If you are using an object and don't want the block to run every time, you need to remember to put your own comparison with the old value in place and not run your logic if there was no change.

Gotcha #3: The evil micro-task (well, sometimes...)

Alright, so far we were just warming up. This one comes in multiple flavours. I will demonstrate the two most common ones. You see, Svelte batches some operations (namely reactive blocks and DOM updates) and schedules them at the the end of the updates-queue - think requestAnimationFrame or setTimeout(0). This is called a micro-task or tick. One thing that is especially puzzling when you encounter it, is that asynchrony completely changes how things behave because it escapes the boundary of the micro-task. So switching between sync/ async operations can have all sorts of implications on how your code behaves. You might face infinite loops that weren't possible before (when going from sync to async) or face reactive blocks that stop getting triggered fully or partially (when going from async to sync). Let's look at some examples in which the way Svelte manages micro-tasks results in potentially unexpected behaviours.

3.1: Missing states

How many times did the name change here?

<script>
    let name = "Sarah";
    let countChanges = 0;
    $: {
        console.log("I run whenever the name changes!", name);
        countChanges++;
    }   
    name = "John";
    name = "Another name that will be ignored?";
    console.log("the name was indeed", name)
    name = "Rose";

</script>

<h1>Hello {name}!</h1>
<p>
    I think that name has changed {countChanges} times
</p>
Enter fullscreen mode Exit fullscreen mode

Svelte thinks that the answer is 1 while in reality it's 3.
As I said above, reactive blocks only run at the end of the micro-task and only "see" the last state that existed at the time. In this sense it does not really live up to its name, "reactive", because it is not triggered every time a change takes place (in other words it is not triggered synchronously by a "set" operation on one of its dependencies as you might intuitively expect).

Solution to 3.1:

When you need to track all state changes as they happen without missing any, use a store instead. Stores update in real time and do not skip states. You can intercept the changes within the store's set function or via subscribing to it directly (via store.subscribe). Here is how you would do it for the example above

3.2 - No recursion for you

Sometimes you would want to have a reactive block that changes the values of its own dependencies until it "settles", in other words - good old recursion. Here is a somewhat contrived example for the sake of clarity, so you can see how this can go very wrong:

<script>
    let isSmallerThan10 = true;
    let count = {a:1};
    $: if (count.a) {
        if (count.a < 10) {
            console.error("smaller", count.a);
            // this should trigger this reactive block again and enter the "else" but it doesn't
            count = {a: 11}; 
        } else {
            console.error("larger", count.a);
            isSmallerThan10 = false;
        }
    }
</script>

<p>
    count is {count.a}
</p>
<p>
    isSmallerThan10 is {isSmallerThan10}
</p>

Enter fullscreen mode Exit fullscreen mode

It doesn't matter whether count is a primitive or an object, the else part of the reactive block never runs and isSmallerThan10 goes out of sync and does so silently (it shows true event though count is 11 and it should be false).
This happens because every reactive block can only ever run at most once per tick.
This specific issue has hit my team when we switched from an async store to an optimistically updating store, which made the application break in all sorts of subtle ways and left us totally baffled. Notice that this can also happen when you have multiple reactive blocks updating dependencies for each other in a loop of sorts.

This behaviour can sometimes be considered a feature, that protects you from infinite loops, like here, or even prevents the app from getting into an undesired state, like in this example that was kindly provided by Rich Harris.

Solution to 3.2: Forced asynchrony to the rescue

In order to allow reactive blocks to run to resolution, you'll have to strategically place calls to tick() in your code.
One extremely useful pattern (which I didn't come up with and can't take credit for) is

$: tick().then(() => {
  //your code here
});
Enter fullscreen mode Exit fullscreen mode

Here is a fixed version of the isSmallerThan10 example using this trick.

Summary

I showed you the most common Svelte reactivity related gotchas, based on my team's experience, and some ways around them.

To me it seems that all frameworks and tools (at least the ones I've used to date) struggle to create a "gotchas free" implementation of reactivity.

I still prefer Svelte's flavour of reactivity over everything else I've tried to date, and hope that some of these issues would be addressed in the near future or would at least be better documented.

I guess it is inevitable that when using any tool to write production grade apps, one has to understand the inner workings of the tool in great detail in order to keep things together and Svelte is no different.

Thanks for reading and happy building!

If you encountered any of these gotchas in your apps or anything other gotchas I didn't mention, please do share in the comments.

💖 💪 🙅 🚩
isaachagoel
Isaac Hagoel

Posted on October 5, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related