-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Proxied state #9739
Proxied state #9739
Conversation
Love this. Was really annoyed at the possibly of having to write more verbose code |
Nested fine-grained reactivity is a big addition, but what's the reason for having it not extend to custom classes? I'd prefer to have my application logic decoupled in my class, and have changes in the object (that the class has spawned) automatically updated in the svelte ui layer, which seems like a sensible way of setting all up. Currently, this won't update: const game = $state(new Game())
let selectedArtists = $derived(game.quizSelection) |
@MentalGear Your class can have |
Thanks for the quick reply, @trueadm. I understand there's a worry about cascading update dependencies and possible self-references.
Using $ Most would agree that one of Svelte's greatest assets is that is In that sense, I'm wondering whether it would be possible to let the compiler check on |
Heuristics that work in some cases but not others would be a big no-go, making the whole system hard to understand. Instead there's one simple rule: POJOs get automatic deep reactivity, everything else doesn't - something you can also use to your advantage. |
Depends on the heuristics, as always of course :P. But yes, it would be a challenge, especially when not in a statically typed environment. Why not an escape hatch, optional advanced setting for people who know what they are doing? $state(gameObject, { customClassReactivity: true }) TBH though, I feel frustrated by how close svelte 5 reactivity is though yet not there on providing full reactivity while also allowing for full separation of concerns - if it would only allow to work for custom classes on demand. |
The newly introduced deeply reactive $state feature is a significant highlight, but its application appears deliberately limited. While it offers full reactivity for object literals, this does not extend to class instances, despite the technical feasibility of such an implementation. I understand the intention to shield less experienced users from potential side-effects. However, I propose a more sophisticated option for those proficient in its usage. Custom classes are widely used for crafting clean and maintainable code. It seems counter-intuitive for Svelte to impose restrictions that hinder the use of such patterns, especially for users who are adept at employing custom classes effectively. An enhancement to $state, perhaps through an additional method like $state.full(), would be beneficial. This improvement would allow nested objects within custom classes to fully leverage Svelte 5's reactive capabilities. Technically, as demonstrated in the provided link FF vs CustomClass, full reactivity for custom objects altering their own behavior is indeed achievable. By enabling this enhanced reactivity, Svelte can offer developers greater flexibility and efficiency in managing state changes, particularly in complex applications where decoupling and adherence to best practices are crucial for maintainability and scalability. |
We're not going to start applying this to class instances. It's a can of worms full of edge cases. It's very easy to achieve what you want by slightly adjusting your class to use |
i agree with @MentalGear, this limitation is quite bothering and not intuitive at all. Took much time to find this post while trying to understand why v2 will update and not v1
So, ok, let's go for a *.svelte.ts since you won't change anything. |
@superkeil I disagree. I look at it as classes allow you to be super fine grained with which properties are stateful and reactive while objects are auto opted in to reactivity lazily when the property is accessed so you don’t have to think about it. Classes can be seen as a more advanced use case when performance is the goal but it’s also super nice as a container of state makes the code much cleaner imo |
@Antonio-Bennett you can disagree all you want. 0.1+0.2===0.3 is false, so not intuitive, you cannot say that it is a feature, but a limitation inherent in the lack of float precision. As long as a workaround exists, it is ok for me. |
Alright, buckle up — this is a chunky one. First, some context:
$state
specifically) has been very positive, especially among people who have encountered the limits of Svelte-4-style reactivity and the store API. One consistent negative reaction has been that it's too cumbersome to create nested reactivity — our initial suggestion involved manually writingget
/set
properties on objects that referenced local$state
, and we followed that up with class state fields, but it still didn't feel sufficiently Svelte-likeobj.x += 1
are problematic insofar as they mutate the value ofobj
instead of creating a new value. At the same time however,obj = { ...obj, x: obj.x + 1 }
is much uglier to write, and involves overhead in cloning the objectThe tl;dr — we heard your feedback. This PR changes the behaviour of
$state
to make reactivity nested by default, which means you can (for example) write code like this:Notice that we're using
todos.push
directly (without atodos = todos
reassignment, as is necessary in Svelte 4), and we're not declaringdone
andtext
as reactive$state
nor declaring aTodo
class as shown today. Despite this, updates are fine-grained, and performance is roughly comparable with the current Svelte 5 approach.Under the hood this uses proxies, lazily creating signals as necessary to keep the UI in sync with your state. Our compiler-driven approach has some advantages over alternatives — reactivity is preserved if you reassign state, we don't need to create wrappers with e.g. a
.value
property, and we have a unified approach whether your state is primitive (like a number) or not (like an array or object). In SSR mode, everything dissolves away.It's not done yet — there are several TODOs:
each
blocks don't currently update if you reassign the valueexport const global_preferences = $state({...})
)Follow-ups:
Map
andSet
That's the summary. Try it out and let us know what you think. We're pretty excited about this change — it doubles down on the things people love about Svelte 4, while aligning with our long term vision — and hope that you are too. Feel free to skip the rest of this post, which will take us into the weeds, or read on if you want the gory details.
How it works
As mentioned, this is based on proxies, which give us a way to intercept reads and writes of object and array properties. The meat of the thing is proxy.js, which proxies its argument unless it's a) already been wrapped or b) it's a primitive or a non-POJO. (Of course, since we're a compiler, we don't even need to call it in the first place if we know that the state in question is a primitive, e.g.
$state(true)
.)When reading a property in an effect or in the template (such as
<p>{obj.message}</p>
), a signal is created for the property if appropriate. When the property is later assigned to, anything that depends on it will update. Updating an unrelated property (obj.whatever
) will not trigger updates, since from the perspective of the signal graphobj
itself is unchanged.Performance
Wrapping things in proxies isn't free. In addition to the creation cost, there's a cost to reading and writing values via the proxy.
In practice, we've been pleasantly surprised by how slight the impact is. Since this approach makes everything effortlessly fine-grained, and since our implementation avoids as much overhead as possible (for example, avoiding closures for the proxy traps), the net effect on performance is modest. It's unlikely that you'll hit problems as a result of this change.
Nevertheless, we're keeping the possibility of an opt-out mechanism in the back of our minds, in case that proves necessary in future.
New capabilities
As mentioned, we can now (finally!) update arrays with methods like
array.push(thing)
. (New values likething
are also made deeply reactive.) One area we plan to explore in future is using our understanding of methods likepush
to optimise things likeeach
blocks — for example, you don't need to check existingeach
block items if you know that there's only new stuff to append to the end.We can also loosen the restrictions around exporting state from shared modules. Previously, this sort of thing was impossible...
...because an importer of
global_preferences
wouldn't know that it's a signal rather than a normal JavaScript value. With this new approach, we don't need to create a signal for the top-level object — only its properties — so it's fine to export it. (The exception would be if we reassignedglobal_preferences
rather than simply changing its properties — in that case,export
would be forbidden, because the compiler would need to turn it into a signal.)Preventing bugs
Making things deeply reactive is convenient but dangerous — if two unrelated components both have references to some shared state, then mutations in one might cause unexpected updates in the other. This coordination problem (between the authors of those two components) can arise in large codebases.
Our plan to prevent this category of errors is to make reactive objects readonly in child components. In other words, here...
...the
<Child>
component should not be able to update anything insidefoo
. At dev time (and maybe prod? depending on whether there's noticeable performance overhead to creating a readonly view of the data), assignments would result in a useful diagnostic error.Of course, if you want to allow the child to update
foo
then you can do so withbind:foo={stuff.foo}
. (This is sort of what happens in Svelte 4 today, except that it's an illusion — in Svelte 4, the child can mutate the object but the paren't just won't 'notice', resulting in differing views of reality. This is very bad.)In addition, the
$inspect
rune (see the docs and this issue) makes it very easy to locate the origin of an unexpected change.Does this work in non-runes mode?
It does not. We're preserving the existing Svelte 4 semantics as closely as possible in order to keep breaking changes to an absolute minimum. If you want to use the new stuff, opt in to runes mode.
Non-POJOs
State is made deeply reactive, to the extent that it includes plain old JavaScript objects (POJOs) and arrays. (We plan to proxy
Map
andSet
as well, since these are built-in JavaScript types that benefit from reactivity.)If your data contains non-POJOs (such as your own
Todo
class, or even something exotic like a DOM node) those things will be ignored, on the basis that you probably know what you're doing. The same is true for non-writable properties like getters and setters:Didn't you say something about immutability? This doesn't seem very immutable.
Yes. It's a little confusing. Bear with me.
First, a primer on the jargon. In JavaScript, everything is mutable, which is to say that you can change the contents of an array or object without changing the identity of the thing itself — things are identical even if they're not equal across two points in time. Conversely, you can have two same-shaped arrays/objects which are nonetheless considered different things — they can be equal but not identical:
Historically, Svelte has leaned into mutability to a much greater degree than many other frameworks. If you assign to a property of an object (e.g.
obj.x += 1
) then we regardobj
as having changed even though it's still the same object — this is how{obj.x}
in your template reacts to those updates.Unfortunately,
{obj.y}
would also be invalidated in that case. (There's animmutable
compiler option that changes the semantics of assignments, but no-one really understands or uses it, and as of this PR it's deprecated.) Svelte avoids triggering a DOM update if it hasn't actually changed, but there's still a cost to invalidating everything connected toobj
rather than just the thing that actually changed.In frameworks that rely on a simple
===
check, this doesn't work — you need to supply a new value ofobj
, like this:This also invalidates everything downstream of
obj
, but there's an additional cost to cloning the object. So Svelte's historical decision made sense — mutability provides better ergonomics and better performance in a world of top-down updates.In a world of fine-grained updates, the calculus changes. It's now much better if we can just ignore the things that haven't changed. Treating data as immutable lets us do that, and also opens up other possibilities that we plan to explore in future versions.
"But wait," you say, "you're telling us that we can still do
obj.x += 1
— isn't that mutation?"Well, yes and no. We're using the syntax of mutation, but under the hood it's really more like
obj_x += 1
, if that makes sense. We can have our cake (the sweet, sweet ergonomics of idiomatic mutation-laden JavaScript) and eat it too (the guarantees around performance and predictability and correctness that come with immutable data structures).We hope you like it.
Before submitting the PR, please make sure you do the following
feat:
,fix:
,chore:
, ordocs:
.Tests and linting
pnpm test
and lint the project withpnpm lint