Skip to content
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

Merged
merged 70 commits into from
Dec 4, 2023
Merged

Proxied state #9739

merged 70 commits into from
Dec 4, 2023

Conversation

Rich-Harris
Copy link
Member

@Rich-Harris Rich-Harris commented Dec 1, 2023

Alright, buckle up — this is a chunky one. First, some context:

  • In general, the feedback on runes (and $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 writing get/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-like
  • Because of future ambitions that we have, we'd like to nudge the Svelte universe away from object and array mutation. That is to say, things like obj.x += 1 are problematic insofar as they mutate the value of obj 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 object

The 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:

<script>
  let todos = $state([]);

  function remaining(todos) {
    console.log('recalculating');
    return todos.filter((todo) => !todo.done).length;
  }

  function addTodo(event) {
    if (event.key !== 'Enter') return;

    todos.push({
      done: false,
      text: event.target.value
    });

    event.target.value = '';
  }
</script>

<input on:keydown={addTodo} />

{#each todos as todo}
  <div>
    <input bind:value={todo.text} />
    <input type="checkbox" bind:checked={todo.done} />
  </div>
{/each}

<p>{remaining(todos)} remaining</p>

Notice that we're using todos.push directly (without a todos = todos reassignment, as is necessary in Svelte 4), and we're not declaring done and text as reactive $state nor declaring a Todo 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 value
  • export shared state from a module (export const global_preferences = $state({...}))
  • prevent child components mutating state without permission
  • various forms of optimisation, such as skipping top-level signal creation for non-reassigned state
  • documentation!

Follow-ups:

  • support for Map and Set
  • change or remove compiler warnings that are no longer applicable
  • add an opt-out mechanism
  • make prop default values readonly

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 graph obj 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 like thing are also made deeply reactive.) One area we plan to explore in future is using our understanding of methods like push to optimise things like each blocks — for example, you don't need to check existing each 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...

export let global_preferences = $state({
  theme: 'dark',
  volume: 1,
  coffee: 'black, no sugar'
});

...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 reassigned global_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...

<script>
  import Child from './Child.svelte';

  let stuff = $state(...);
</script>

<Child foo={stuff.foo} />

...the <Child> component should not be able to update anything inside foo. 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 with bind: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 and Set 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:

<script>
  let numbers = $state({
    a: 1,
    b: 2,
    get total() {
      return this.a + this.b
    }
  });
</script>

<p>
  <input type="number" bind:value={numbers.a}> +
  <input type="number" bind:value={numbers.b}> =
  {numbers.total}
</p>

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:

let array = ['hello'];
let spare = array;

spare; // ['hello']

array === ['hello']; // false! equal, but not identical

array.push('goodbye');

array; // ['hello', 'goodbye']
array === spare; // false! identical, but not equal (to what it was when `spare` was assigned)

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 regard obj 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 an immutable 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 to obj 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 of obj, like this:

setObj({ ...obj, x: obj.x + 1 });

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

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • Prefix your PR title with feat:, fix:, chore:, or docs:.
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.

Tests and linting

  • Run the tests with pnpm test and lint the project with pnpm lint

@sharu725
Copy link
Contributor

sharu725 commented Dec 9, 2023

image

@AlbertMarashi
Copy link

Love this. Was really annoyed at the possibly of having to write more verbose code

@MentalGear
Copy link

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)

@trueadm
Copy link
Contributor

trueadm commented Jan 21, 2024

@MentalGear Your class can have $state as class properties if you want that behavior. The reason why we can't just do it all objects is because then we'd possibly start applying the proxying to things like window, document and other DOM elements. The list goes on.

@MentalGear
Copy link

MentalGear commented Jan 21, 2024

Thanks for the quick reply, @trueadm. I understand there's a worry about cascading update dependencies and possible self-references.

Your class can have $state as class properties if you want that behavior.

Using runes in a class would make it not framework-agnostic any more, also unit tests and TTD would becoming harder to add (besides having an akward .svelte.ts file 😉 ).

$
I do think that fine-grained, nested reactivity can be a tent-pole feature of svelte 5 (terrific work!) clearing up black-magic (like obj = obj) and make it much more idiomatic and easy to code along.

Most would agree that one of Svelte's greatest assets is that is just works.
However, when expecting full reactivity while the reality being that custom classes (which are common enough!) are just not reactive, introduces again other black magic/hidden know-how one has to have in order not fall into frustration pits. And I'm afraid svelte's example-heavy docs are just not clear enough to desire spending long time with them, being a turn off to take on runes.

In that sense, I'm wondering whether it would be possible to let the compiler check on $state init whether a custom class has excessive dependencies. For most custom classes this won't be the case, but for those edge cases that you mentioned where someone tries to pass in something big like window etc, it could be restricted from execution with a warning in the console.

@dummdidumm
Copy link
Member

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.

@MentalGear
Copy link

MentalGear commented Jan 21, 2024

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.

@MentalGear
Copy link

MentalGear commented Jan 29, 2024

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.

@dummdidumm
Copy link
Member

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 $state inside. If we were to blindly apply it to all classes, you might get reactivity for things that shouldn't have it. Classes are manually crafted and under your control - this is a feature, not a limitation.

@superkeil
Copy link

superkeil commented Aug 5, 2024

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

	let values = [];
	const v1 = $state(new Person('Creed'));
	values.push(v1);
	const v2 = $state({name: 'Creed'});
	values.push(v2);

So, ok, let's go for a *.svelte.ts since you won't change anything.
This is a lame black magic as good as the old former value = value to force rerendering.

@Antonio-Bennett
Copy link

@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

@superkeil
Copy link

@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.
It is not intuitive at all and should be documented

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.