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} />
The intended behavior for the code snippet above is to
- reactively update
bwhenachanges - allows
btemporarily go “out-of-sync” ofawhen callingupdate, settingbto42- in this case,
bis not alwaysa * 2
- in this case,
- however, if
achanges again,bwill be updated back toa * 2, instead of staying at42
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
ais exported (changing the example toexport let a = 1, or - if
ais mutated / reassigned, eg:<input bind:value={a} />or havingfunction 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:
- https://github.com/sveltejs/svelte/issues/4613
- the base case of the 1st 2 examples is that condition 1 & 3 has met, the author reported the behavior of condition 2, changing the dependencies from primitives to object
- https://github.com/sveltejs/svelte/issues/4448
- base case: condition 2 & 3 has met, author reported the behavior of condition 1
- https://github.com/sveltejs/svelte/issues/5363
- base case: condition 2 & 3 has met, author reported inconsistency behavior when meeting the condition 1
- https://github.com/sveltejs/svelte/issues/4933
- https://github.com/sveltejs/svelte/issues/6507 + related to https://github.com/sveltejs/svelte/issues/2444 that defines the dependencies of the reactive declarations
About this issue
- Original URL
- State: closed
- Created 4 years ago
- Reactions: 24
- Comments: 25 (11 by maintainers)
My mental model has always been that a reactive declaration like
$: b = a * 2definesbin it’s entirety (“Here’s my recipe forband all the ingredients, please Svelte makebalways up to date”). And outside of this declarationbis immutable. I never had the use-case to touchbbecause that might cause my brain to melt when I manually need to think about whatbcould 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:
The first example defines a “recipe” for how to create
bandbis completely defined by that declaration. Outside of that it is immutable, data flows only into a single sink.The second example declares a variable
band then uses a reactive statement to update it. But it also allows you to do withbwhatever 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:
The reactive variable
nameis 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:When using
bind:valuewe 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:
which generates
$someStoreis only “indirectly” used for computing the value ofname, 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
ifexample above).I started to look into the implementation a bit. The “problem” is that the logic for generating the
$$invalidatecalls (ininvalidate.tsdoesn’t know know anything about the nature of the passed in name. E.g. if we bind to an object property likebind:value={user.name}then this logic gets passeduserand 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:
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:
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:
Something like
$: variable = ...for one-way, and$bind: variable = ...for two-waySimilar to how binding comes in 2 flavors too, one-way binding
<input {value}/>, and two-way binding<input bind:value/>.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:Svelte 5 fixes this by separating these into two distinct runes,
$effectand$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$statevariable and either update that through the binding of from an$effect, resulting in the desired behavior.Just wanted to share that this might be solved in Svelte 5, using
$state()and$effect()runes to achieve reactivity instead of the svelte 4 reactive declaration.Svelte 4 reproduction (can’t update b): https://svelte.dev/repl/f5cfadae61e14fe989d0afe7496ebe1e?version=3.23.0 Svelte 5 reproduction (works as expected): https://svelte-5-preview.vercel.app/#H4sIAAAAAAAAE3WRwWrDMAyGX0WYHtIxGlZ68ppA-w47LTvYjgJmiWMSuTCM3312nJAetpul__ul38izTvc4M_7pmREDMs5u1rJXRj82FfMDe8JYz6ObVOpcZzVpS3VjGuqRQEAFh5kEYeEfonfI4S0c3zdZ7rI4LTq8wDnrB-w6VFQUR6hq8KnVUDI8kQuY58VH54wiPRpwto0jb9G52lKMbf_lHLLtD8t9t6RNl7wgF3JzXcv9k0Zw8GugADIWMhHSEcWho-Gq1-q78muiUMPH8gJxLTNU_4_fQ73S8pnWxjqCdIKqYcYNEqeGgdSm5UuOag9URj6eZxhb3WlsGacptr_CL6ayI9nZAQAA
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:
valueandvalueUnsaved. It’s similar to the example on my comment above. To avoid mutating the original object directly, I assignvalueUnsavedas a deep clone ofvalue. Ifvalueis changed outside of the component,valueUnsavedshould 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