svelte: inconsistent behavior when updating reactive declared variable

Describe the bug I am not sure what is the expected behavior when updating reactive declared variable, but here are the inconsistencies that I have found.

First of all, here is what I meant by updating reactive declared variable

<script>
   let a = 1;
   $: b = a * 2;

   function update() {
      b = 42;
   }
</script>
a: {a} b: {b}
<button on:click={update}>Update b</button>
<input bind:value={a} />

REPL

The intended behavior for the code snippet above is to

  • reactively update b when a changes
  • allows b temporarily go “out-of-sync” of a when calling update, setting b to 42
    • in this case, b is not always a * 2
  • however, if a changes again, b will be updated back to a * 2, instead of staying at 42

I used the word “intended” behavior, because that is the behavior im looking for, but I may not be expressing it correctly in Svelte. It may not be the expected behavior of the code.

In the example above, the REPL behaves as intended, however, it will break if all of the following conditions are met:

Condition 1: any of the dependencies of the reactive declarations is mutated, reassigned, or exported in this case

  • if a is exported (changing the example to export let a = 1, or
  • if a is mutated / reassigned, eg: <input bind:value={a} /> or having function foo() { a = 5 };

Condition 2: the dependencies of the reactive declarations that is mutated, reassigned or exported is not a primitive this is because of the behavior of $$invalidate, Svelte uses safe_not_equal to decide whether the updated value is the same as the current value during $$invalidate. comparing objects with safe_not_equal will always return true, because Svelte allows user to mutate the object / array directly, therefore should always $$invalidate them.

  • try changing the example to a = { v: 1 } and $: b = a.v * 2

Condition 3: using bind: or value = value when updating the reactive declared variable this is kind of the edge case that wasn’t handled properly in https://github.com/sveltejs/svelte/blob/master/src/compiler/compile/render_dom/Renderer.ts#L154

  • try changing the example to function update() { b = 42; b = b; } or adding <input bind:value={b} />

The behavior of the bug was introduced in https://github.com/sveltejs/svelte/issues/2444.

The intention of the issue #2444 was to propagate the changes of the reactive declared variables back to its dependencies. in the context of this example, would meant, updating b should update a as well. updating a will update b back again in the reactive declaration.

It works if you always want b to be the value deriving from a. However in the example above, we want the value of b to be temporarily out of sync of a.

I dont know what is the expected behavior of the Svelte should be, but having inconsistencies when all the “subtle” conditions were met is unfriendly. it requires the user to have much deeper understanding of the nuances of the language.

Related issues that are symptom to this inconsistencies:

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Reactions: 24
  • Comments: 25 (11 by maintainers)

Most upvoted comments

My mental model has always been that a reactive declaration like $: b = a * 2 defines b in it’s entirety (“Here’s my recipe for b and all the ingredients, please Svelte make b always up to date”). And outside of this declaration b is immutable. I never had the use-case to touch b because that might cause my brain to melt when I manually need to think about what b could be at any given moment. If it’s immutable and completely defined by the reactive declaration then it is easy to look at the code (yay code quality).

I personally think the compiler should prevent any mutation of these reactively declared variables. That would get rid of all these edge cases at the root. If you need this behavior, you can always achieve it, see below.

For me there is a distinct difference between these two scripts:

let a = 1;
$: b = a * 2;
let a = 1;
let b;
$: {
    b = a * 2
};

The first example defines a “recipe” for how to create b and b is completely defined by that declaration. Outside of that it is immutable, data flows only into a single sink.

The second example declares a variable b and then uses a reactive statement to update it. But it also allows you to do with b whatever you want. If someone wants to go that route (definitely not me), they are free to do so at their own risk of ensuring consistency.

TLDR: Introduce a compiler option that makes reactive declaration in the first example immutable. Remove the option in v4 and make it the default. Anyone in the same boat as me?

I’ve also encountered this surprising behavior and dug a little bit into it. I’m probably repeating a lot of things that have already been said.

I think regardless of whether assigning to a reactive variable should be allowed or not, the behavior overall should still be correct and consistent in itself. Take the following combined example:

<script>
	export let user;
	$: name = user.name
</script>

<input bind:value={name} />
<input value={name} on:change={event => name = event.target.value}>

The reactive variable name is updated via binding and via “manual” assignment. In general I would expect both of these behaviors to be equivalent, but the generated code for both event handlers is different:

let name;
let { user } = $$props;

function input0_input_handler() {
  name = this.value;
  ($$invalidate(0, name), $$invalidate(1, user));
}

const change_handler = event => $$invalidate(0, name = event.target.value);

When using bind:value we also invalidate the dependencies of the reactive variable, but we don’t do that in the assignment case…

I now understand that invalidating dependencies was introduced by #2444, but I think the logic is not granular enough. In JavaScript, assigning to a variable can never have an impact on the values/variables from which this variable was initialized. I think the issue becomes even clearer in the following example:

<script>
	export let user;
	let name
	$: if ($someStore) {
		name = user.name
	}
</script>

<input bind:value={name} />

which generates

let $someStore;
component_subscribe($$self, someStore, $$value => $$invalidate(2, $someStore = $$value));
let { user } = $$props;
let name;

function input_input_handler() {
  name = this.value;
  (($$invalidate(0, name), $$invalidate(2, $someStore)), $$invalidate(1, user));
}

$someStore is only “indirectly” used for computing the value of name, yet it’s invalidated too. I think that’s conceptually wrong, regardless of whether assigning to a reactive variable should be allows or not.

What makes the matter worse (and which has been mentioned already) is that sometimes invalidating the dependencies will have an effect (if the dependency is an object value) or not (if the dependency is a primitive). This behavior is not obvious to someone who just uses bind:value.

To me a possible solution seems to be that when binding to a reactive variable, dependencies should be invalidate iff the binding target is not a variable and the dependency is directly used in computing the variable’s value (to prevent the if example above).

I started to look into the implementation a bit. The “problem” is that the logic for generating the $$invalidate calls (in invalidate.ts doesn’t know know anything about the nature of the passed in name. E.g. if we bind to an object property like bind:value={user.name} then this logic gets passed user and at this point the information that a property is bound is not available.

I’ve come across this bug in the enterprise app I’m working on, and it’s a serious pain in the ass for us. We need to fetch data from the backend every time a prop updates, but this bug invalidates the prop again and again leading to an infinite loop.

Pseudocode showing our use case:

export let url;
$: content = url;
$: fetchData(url);

function fetchData(url) {
  fetch(url).then(c => content = c); // invalidates url => runs in an infinite loop
}

Actual code from our application in the REPL.

I don’t see any simple walkaround, and I couldn’t find any version of Svelte where this works properly.


EDIT: I did find a walkaround, inspired by madacol’s comment. The key is not letting Svelte know that the dependent variable relates to the prop in any way, wrapping the relation in a function. The pseudocode from before would look like this:

export let url;
let content;
$: fetchContent(url);

function fetchContent(url) {
  content = url;
  fetch(url).then(c => content = c); // only invalidates content, as Svelte doesn't know it's a dependency of url
}

Actual code in the REPL.

I still maintain that this is a bug, as the behavior is very unintuitive and I don’t see any good reason for Svelte behaving in this way.

Within my own ignorance, I feel like there should be 2 separate ways of “reactive declarations”, depending on how the invalidate moves the dependency tree:

  1. one-way (downward)
  2. two-way (downward/upward)

Something like $: variable = ... for one-way, and $bind: variable = ... for two-way

Similar to how binding comes in 2 flavors too, one-way binding <input {value}/>, and two-way binding <input bind:value/>.

@benmccann My case is that I have a component which takes an object as a prop. As the user changes the object’s values, they can either Save or Cancel. So in the component I have two variables: value and valueUnsaved. It’s similar to the example on my comment above. To avoid mutating the original object directly, I assign valueUnsaved as a deep clone of value. If value is changed outside of the component, valueUnsaved should be updated. But unfortunately I couldn’t get this to work.

Same here, and I think this is not correct. The more I think about it, the more I am convinced it’s a wrong mental model.

This comes down to $: standing for two things and it’s not easy to distinguish:

  • side effects that happen in reaction to something
  • derived state that keeps values in sync

Svelte 5 fixes this by separating these into two distinct runes, $effect and $derived. That way it’s much clearer what’s going on and such edge cases are avoided entirely. In this case, you would have a $state variable and either update that through the binding of from an $effect, resulting in the desired behavior.

I agree with @fkling here. I was struggling with these issues significantly while starting to actually use Svelte for a real project. I’m concerned that the unpredictability of the reactive behavior is going to be a major turn-off for others on my team who are less experienced with programming (designers that dip into code when they have to).

Regardless of whether you divide up reactive declarations to account for one-way vs. two-way binding via syntax changes, before that, there needs to be a pristine, consistent, and intuitive clarity by which Svelte users can engage with reactive declarations/statements overall.

@benmccann My case is that I have a component which takes an object as a prop. As the user changes the object’s values, they can either Save or Cancel. So in the component I have two variables: value and valueUnsaved. It’s similar to the example on my comment above. To avoid mutating the original object directly, I assign valueUnsaved as a deep clone of value. If value is changed outside of the component, valueUnsaved should be updated. But unfortunately I couldn’t get this to work.

When Rich introduced Svelte, he used spreadsheets like Excel as a model to explain reactivity. In Excel, you can only define a cell’s value with a single formula. You can’t have multiple things updating a cell’s value. And I think that makes sense because I don’t know how things would behave otherwise. So if we think about reactivity in those terms, then @Prinzhorn’s suggestion that you not be able to mutate a reactive variable outside of its declaration makes a lot of sense. My initial reaction is that I don’t think that would be limiting because you can express almost anything in Excel and it’s a tool a lot of people were able to easily learn to use.

I’m not sure by looking at the description of this issue that I totally understood at first how difficult some of these cases might be to model and think about. Going off the issue description, I’d probably just say that the value should end up as 42. But some of these cases can get more confusing. E.g. looking at https://github.com/sveltejs/svelte/issues/6720 you have to start doing more mental gymnastics to explain how things would work