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

feat: add onchange option to $state #15069

Open
wants to merge 24 commits into
base: main
Choose a base branch
from
Open

feat: add onchange option to $state #15069

wants to merge 24 commits into from

Conversation

paoloricciuti
Copy link
Member

@paoloricciuti paoloricciuti commented Jan 20, 2025

Closes #15032

This adds an onchange option to the $state runes as a second argument. This callback will be invoked whenever that piece of state changes (deeply) and could help when building utilities where you control the state but want to react deeply without writing an effect or a recursive proxy.

Edit: @brunnerh proposed a different transformation which is imho more elegant...gonna implement that tomorrow...instead of creating a state and a proxy externally we can create a new function assignable_proxy that we can use in the cases where we would do $.state($.proxy()) do that we can not declare the extra const or private_id. We could also pass a parameter to set to specify if we should proxify or not and that could solve the issue of get_options.

Edit edit: I've implemented the first of the above suggestions...gonna see what is doable with set later...much nicer.

Edit edit edit: I've also implemented how to move the proxying logic inside set...there's one issue which i'm not sure if we care about: if you reassign state in the constructor of a class we don't call set do it's not invoking the onchange...we can easily fix this with another utility function that does both things but i wonder if we should.

A couple of notes on the implementation. When initialising a proxy that is also reassigned we need to pass the options twice, once to the source and once to the proxy...i did not found a more elegant way so for the moment this

let proxy = $state({count: 0}, {
    onchange(){}
});

is compiled to

let state_options = { onchange() {} },
		proxy = $.state($.proxy({ count: 0 }, state_options), state_options);

if the proxy is reassigned.

Then when the proxy is reassigned i need to pass the same options back to the newly created proxy. To do so i exported a new function from the internals get_options so that when it's reassigned the proxy reassignment looks like this

$.set(proxy, $.proxy({ count: $.get(proxy).count + 1 }, $.get_options(proxy)))

the same is true for classes...there however i've used an extra private identifier to store the options

class Test{
	proxy = $state([], {
		onchange(){}
	})
}

is compiled to

class Test {
	#state_options = { onchange() {} };
	#proxy = $.state($.proxy([], this.#state_options), this.#state_options);

	get proxy() {
		return $.get(this.#proxy);
	}

	set proxy(value) {
		$.set(this.#proxy, $.proxy(value, $.get_options(this.#proxy)));
	}
}

there's still one thing missing: figure out how to get a single update for updates to arrays (currently if you push to an array you would get two updates, one for the length and one for the element itself.

Also also currently doing something like this

let foo = $state({}, {
   onchange: () => console.log('foo')
});
let bar = $state(foo, {
   onchange: () => console.log('bar')
});

and updating bar will only trigger the update for foo.

Finally the onchange function is untracked since it will be invoked synchronously (this will prevent updating from an effect adding an involountary dependency.

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.
  • If this PR changes code within packages/svelte/src, add a changeset (npx changeset).

Tests and linting

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

Copy link

changeset-bot bot commented Jan 20, 2025

🦋 Changeset detected

Latest commit: 35e2afe

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
svelte Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@Rich-Harris
Copy link
Member

preview: https://svelte-dev-git-preview-svelte-15069-svelte.vercel.app/

this is an automated message

Copy link
Contributor

Playground

pnpm add https://pkg.pr.new/svelte@15069

@Ocean-OS
Copy link
Contributor

Would there also be an onchange option for $state.raw?

@paoloricciuti
Copy link
Member Author

Would there also be an onchange option for $state.raw?

Yes (uh I need to update types for that too)

@Ocean-OS
Copy link
Contributor

Ocean-OS commented Jan 20, 2025

I like this, but also I'm thinking that there are more cases where you get a $state from an external source (props, imported, etc), and you might want to synchronously observe their changes as well.
I'm not sure what the implementation of that would look like, however. Maybe something like a $watch rune, which was proposed somewhere.

@paoloricciuti
Copy link
Member Author

I like this, but also I'm thinking that there are more cases where you get a $state from an external source (props, imported, etc), and you might want to synchronously observe their changes as well. I'm not sure what the implementation of that would look like, however. Maybe something like a $watch rune, which was proposed somewhere.

I think effect or derived with state is the good formula for that already...this aims to solve the problem of getting deep updates for a stateful variable that you own.

@jjones315
Copy link

This would have helped me several times! Figuring out how to watch deep updates was very unintuitive to me.

@Rich-Harris
Copy link
Member

Recapping discussion elsewhere — I think we made a mistake with the implementation of $state(other_state):

let obj = {};

let a = $state(obj);
let b = $state(obj);

console.log(a === b); // false, which is good

let c = $state(b);

console.log(b === c); // true, which is bad

There's no real point in having c be a separate binding with the same value; if you want that just do let c = b. But for some reason we did this:

// if non-proxyable, or is already a proxy, return `value`
if (typeof value !== 'object' || value === null || STATE_SYMBOL in value) {
return value;
}

It would have been better if existing proxies passed directly to $state were snapshotted.


Most of the time this doesn't really matter, because people generally don't use state like that. But we do need to have some answer to the question 'what happens here?' more satisfying than 'we just ignore the options passed to c altogether:

let obj = { count: 0 };

let a = $state(obj, {
  onchange() {
    console.log('a changed');
  }
});

let b = $state(obj, {
  onchange() {
    console.log('b changed');
  }
});

let c = $state(b, {
  onchange() {
    console.log('c changed');
  }
});

I think one reasonably sensible solution would be to throw an error in the let c = $state(b, ...) case if options were passed to either b or c. In Svelte 6 we could go one of two ways — automatically snapshot, or error when any state proxy is passed to $state.

@Ocean-OS
Copy link
Contributor

Ocean-OS commented Jan 21, 2025

I think one reasonably sensible solution would be to throw an error in the let c = $state(b, ...) case if options were passed to either b or c. In Svelte 6 we could go one of two ways — automatically snapshot, or error when any state proxy is passed to $state.

I think it'd probably be best if $state proxies were snapshotted when passed to $state. You don't always know if you're getting a $state proxy (such as in props). I also think it'd be best if this sort of thing were to be fixed earlier, as this doesn't seem like something anyone would want/try to do.

@Rich-Harris
Copy link
Member

Actually, I take it back — in thinking about why we might have made it work that way in the first place, this case occurs to me:

let items = $state([...]);
let selected = $state(items[0]);

function reset_selected() {
  selected.count = 0;
}

That doesn't work if we snapshot at every declaration site (and it certainly wouldn't make sense to snapshot on declaration but not on reassignment).

So I guess automatic snapshotting and erroring are both off the table — we need to keep the existing behaviour for $state(value) today and in Svelte 6. But I do think we should make it an error to do either of these in the case where b is an existing state proxy (with or without an existing options object), because there's no way for Svelte to know which onchange handler to fire when the state is mutated:

let c = $state(b, {
  onchange() {...}
});
let c = $state({}, {
  onchange() {...}
});

c = b;

@Ocean-OS
Copy link
Contributor

Ocean-OS commented Jan 21, 2025

Actually, I take it back — in thinking about why we might have made it work that way in the first place, this case occurs to me:

let items = $state([...]);
let selected = $state(items[0]);

function reset_selected() {
  selected.count = 0;
}

That doesn't work if we snapshot at every declaration site (and it certainly wouldn't make sense to snapshot on declaration but not on reassignment).

So I guess automatic snapshotting and erroring are both off the table — we need to keep the existing behaviour for $state(value) today and in Svelte 6.

I didn't think of that, actually. But couldn't that be fixed with $state.link? That way, we wouldn't have to worry about $state proxies referencing other $state proxies, unless that is the desired behavior:

let items = $state([...]);
let selected = $state.link(items[0]);
function reset_selected() {
    selected.count = 0;
}

@levibassey
Copy link

levibassey commented Jan 21, 2025

Not sure how I feel about this. Shouldn't this just be some kind of effect, that watches deeply?

$effect(() => {
    ...
}, {deep: true})

@Rich-Harris
Copy link
Member

Shouldn't this just be some kind of effect

No. Avoiding effects is the whole point of this. Deep-reading is easy, but often when you want to respond to state changes, the state was created outside an effect (think e.g. an abstraction around localStorage).

Opened #15073 to address an issue with array mutations causing onchange to fire multiple times.

@Rich-Harris
Copy link
Member

There's no version of that that's more readable than including it in the declaration. Using single character names for everything where possible, to highlight the difference, this is 36 characters...

let x = $state(y, { onchange: z });

...while this is 41:

let x = $state(y);

$state.changed(x, z);

So we're off to a bad start. But then consider the fact that you're basically inviting people to separate the declaration from the change handler:

let x = $state(y);

// lots of code...

$state.changed(x, z);

Can you tell, quickly, whether a given piece of state has a change handler? If not, then it's now less readable in two ways.

But there's more! We've subtly changed the meaning of the state binding. Everywhere else you see x referenced, it references the value. But inside $state.changed(...) it references the binding itself. We've introduced a glaring syntactical anomaly. This sets a terrible precedent, and once again harms readability.

What if the first argument isn't a local state binding? I'd expect this to fail...

let potato = 'yukon gold';
$state.changed(potato, () => ...);

...because potato isn't state. But what about this?

let { potato } = $props();
$state.changed(potato, () => ...);

Aren't props state? What about deriveds? (The answer is no, this feature is for sources, not props or derived state; it doesn't make sense in those contexts.) Suddenly we have to devote a bunch of space in the documentation to explaining that nuance, all while making the API itself — you guessed it — less readable.

And doesn't it kind of look like an effect?

$state.changed(foo, () => {
  blah(foo);
});

$effect(() => {
  blah(foo);
});

So now we have these two things that, to the untrained eye, look quite similar (even though they're actually very different things). Pretty confusing.

Remember what this feature is for: it's so that you, the declarer of a piece of state, have a simple way to say 'run this code when this state changes'. It would be weird not to do that at the declaration site.

Shorter answer: no.

@cgaaf
Copy link

cgaaf commented Jan 22, 2025

I have some questions to get a little bit of clarity on the intended use cases for the onchange callback.

1.) Is this intended to perform side effects whenever the state changes? Or put differently, is this analolgus to the $effect rune but limited in scope to changes to a specific declared variable?

  • If this is the case, I do like the ability to be more explicit when defining a side effect for changes to a specific variable. However I also really appreciate how the $effect rune makes it fairly explicit that a side effect is happening and maybe discourages the developer from using it too liberally. I wonder if providing a separate path to perform side effects could potentially encourage some bad habits.

2.) Is this intended to apply transformations to the state of a variable whenever it changes?

  • For example as a way to limit unnecessary DOM updates when a $state variable changes until all of the transformations are complete. I've provided an example of a willSet() callback which provides the new value as a function parameter before a view update is performed. In my mental model this newValue parameter would be an immutable value (but I'm sure there are valid ways to do this as a reference type, but I'm just giving an example with value semantics used often in swift). The callback returns a value
// This would trigger only one view update instead of multiple if these transformations were performed within an $effect rune. 
// Using a callback within the $state willChange handler
let text = $state('', (newValue) => {
    let transformedString = newValue.slice(0,10);
    transformedString.toLowerCase();
    return transformedString;
});

// This change would trigger only one DOM update. 
// Without a 
text = "HeLLo, WOrld";

// Using an $effect rune to transform the string would lead to multiple view updates for one state change. 
let text = $state('');

$effect(() => {
     let transformedString = text.slice(0,10);
     transformedString.toLowerCase();
     text = transformedString;
});

text = "HeLLo, WOrld";

3.) When does the onchange callback run? Does it happen before the DOM is updated? After?

@paoloricciuti
Copy link
Member Author

We can't pass the old and new value to the same callback but we could do the willChange/didChange thing if we really wanted, no? (Not saying we should — haven't really thought about it — just that we could. I think.)

Yeah we could but tbf I don't really see the point

@paoloricciuti
Copy link
Member Author

1.) Is this intended to perform side effects whenever the state changes? Or put differently, is this analolgus to the $effect rune but limited in scope to changes to a specific declared variable?

Is a sort of in-between: it doesn't carry the same weight and even flexibility of an effect (it's not fine grained, it doesn't auto track variables etc etc). But it has the advantage of deeply notify without having to read the values. And you can also use it without an effect.root

2.) Is this intended to apply transformations to the state of a variable whenever it changes?

I wouldn't say it's a use case...it can be done but I would do that at the assignment site...if you reassign the same state in this callback you will cause an unnecessary trigger of everything is listen to it. If you really want to make sure everything is setting this variable has a transformation applied use a function or a getter to pass it around.

3.) When does the onchange callback run? Does it happen before the DOM is updated? After?

Immediately after the value is set so before the Dom update

@Leonidaz
Copy link

Leonidaz commented Jan 22, 2025

I haven't fully thought this through but I think it might be cool to also add onchange option callback to $derived.

Benefits:

  • Deriveds are not recalculated unless read, and only marked as dirty (I think this is sync). So, it creates an opportunity to pass in the "previous" and new values. Yes, they could end up being the same on the new read but hear me out first. A wrapper for a derived can also be created to get the previous value but it's not convenient.
    • As far as the new value, it could only be recalculated once read inside the onchange. But that could also make the previous one "the same" if they point to the same reference. So, there are a couple of ways this could be solved:
      • Implicitly. onchange receives prev and next and if you read the next and need to compare, you should first create some kind of snapshot of theprev by whatever means (e.g. $state.snapshot or something custom). Reading next causes the derived to rerun the calculation and returns the new value.
      • Explicitly. The onchange callback receives 2 functions, prev(fn), next(fn) that can be called to get the value by requiring a custom function for each that creates a snapshot which is returned back. Then the user can make a comparison.
    • To understand what changes took place, the user can also just check the values of the dependencies, wrapped inside untrack(), without reading / recalculating the new derived value.
  • Allows an ability to watch changes across multiple states

@cgaaf
Copy link

cgaaf commented Jan 22, 2025

1.) Is this intended to perform side effects whenever the state changes? Or put differently, is this analolgus to the $effect rune but limited in scope to changes to a specific declared variable?

Is a sort of in-between: it doesn't carry the same weight and even flexibility of an effect (it's not fine grained, it doesn't auto track variables etc etc). But it has the advantage of deeply notify without having to read the values. And you can also use it without an effect.root

2.) Is this intended to apply transformations to the state of a variable whenever it changes?

I wouldn't say it's a use case...it can be done but I would do that at the assignment site...if you reassign the same state in this callback you will cause an unnecessary trigger of everything is listen to it. If you really want to make sure everything is setting this variable has a transformation applied use a function or a getter to pass it around.

3.) When does the onchange callback run? Does it happen before the DOM is updated? After?

Immediately after the value is set so before the Dom update

Thanks for the clarification!

We can't pass the old and new value to the same callback but we could do the willChange/didChange thing if we really wanted, no? (Not saying we should — haven't really thought about it — just that we could. I think.)

If the onchange callback is called immediately after the value is set, could a copy the old value be passed as a single parameter? For example:

let foo = $state(1, (oldValue) => {
   if (foo > oldValue) {
       // Do something
   } else {
       // Do something else
   }
});

@paoloricciuti
Copy link
Member Author

paoloricciuti commented Jan 22, 2025

If the onchange callback is called immediately after the value is set, could a copy the old value be passed as a single parameter?

As I've said before it's very simple to get the previous value if you really need it so I don't think we should add it.

And that would also require screenshot every single value which is very bad for performance

@paoloricciuti
Copy link
Member Author

I haven't fully thought this through but I think it might be cool to also add onchange option callback to $derived.

It doesn't really make sense to have onchange on derived because that's basically just a sync effect (it can't be deep)...and sync effects can be disruptive for the reactivity graph

@7nik
Copy link

7nik commented Jan 22, 2025

Which behavior is expected for subobjects that don't belong to the state with onchange anymore? Example

onchange gets shadowed by onchange in child - example and more tricky variant

Should there be a guard against infinite self-triggering like with $effect?

@paoloricciuti
Copy link
Member Author

Which behavior is expected for subobjects that don't belong to the state with onchange anymore? Example

I don't think we can do anything because the onchange get's assigned at the creation phase and i don't think we can safely detect when it should be removed.

onchange gets shadowed by onchange in child - example and more tricky variant

I think we can do something about this...let me see

Should there be a guard against infinite self-triggering like with $effect?

We can...but maybe it's an overkill? With effects it might be easy to don't realise that you are reading and writing but with this it should be easier to spot.

@rChaoz
Copy link
Contributor

rChaoz commented Jan 22, 2025

But I do think we should make it an error to do either of these in the case where b is an existing state proxy (with or without an existing options object), because there's no way for Svelte to know which onchange handler to fire when the state is mutated

How about this case:

let first = $state({ deep: "object" }, {
    onchange() {
        console.log("first changed")
    }
})
let second = $state({ another: { deep: 123 } }, {
    onchange() {
        console.log("second changed")
    }
})

// all good, right? how about now
second = first
// second === first is true now

first.deep = "new value"
// which onchange triggers here?

IMO I think it's okay if both onchanges trigger

documentation/docs/02-runes/02-$state.md Outdated Show resolved Hide resolved
packages/svelte/src/ambient.d.ts Outdated Show resolved Hide resolved
packages/svelte/src/ambient.d.ts Show resolved Hide resolved
@trueadm
Copy link
Contributor

trueadm commented Jan 22, 2025

I haven't fully thought this through but I think it might be cool to also add onchange option callback to $derived.

Deriveds are pull based only, we only push the notion they are possibly dirty. Deriveds only become dirty when their dependencies invalidate them, which can differ depending on various heuristics (if the derived is owned vs unowned etc). So there's no guarantee on when we can safely invoke onchange.

@paoloricciuti
Copy link
Member Author

I've added a new commit to make so that if you pass a proxy to proxy it will "stack" the onchange didn't have much time to play around with it for edge cases but i have to run now. Will add some test later.

@Conduitry
Copy link
Member

Are we looking for the onchange function at compile time? I think that would be preferable, if possible, rather than giving the runtime to the whole options object and having it call the onchange function from it.

This would prevent code from being written where the options aren't statically analyzable at compile time (which might always be considered a feature), but it would give us more control in the future with additional state options, so they don't all need to be included as-is in the client code.

@paoloricciuti
Copy link
Member Author

Are we looking for the onchange function at compile time? I think that would be preferable, if possible, rather than giving the runtime to the whole options object and having it call the onchange function from it.

This would prevent code from being written where the options aren't statically analyzable at compile time (which might always be considered a feature), but it would give us more control in the future with additional state options, so they don't all need to be included as-is in the client code.

I actually wanted to add an error in case it was not specified inline but for different reasons: in my latest commit I'm changing the original object to chain the onchange. That might result in weird stuff if the object was shared. So I think I will actually add an error if the options are not specified inline which will also satisfy your point

Copy link
Contributor

@rChaoz rChaoz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realised this does not work with nested $state fields in classes. I don't think it should, as these are new states with possibly new onchange fields, but still this should be noted

@@ -147,6 +147,35 @@ person = {

This can improve performance with large arrays and objects that you weren't planning to mutate anyway, since it avoids the cost of making them reactive. Note that raw state can _contain_ reactive state (for example, a raw array of reactive objects).

## State options

Both `$state` and `$state.raw` can optionally accept a second argument. This allows you to specify an `onchange` function that will be called synchronously whenever the state value changes (for `$state` it will also be called for deep mutations).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Both `$state` and `$state.raw` can optionally accept a second argument. This allows you to specify an `onchange` function that will be called synchronously whenever the state value changes (for `$state` it will also be called for deep mutations).
Both `$state` and `$state.raw` can optionally accept a second argument. This allows you to specify an `onchange` function that will be called synchronously whenever the state value changes. For a deep `$state` proxy (of a POJO/array), `onchange` is also called for deep mutations, but not for `$state` fields in classes, as these are owned by the corresponding class instance. This includes classes from `svelte/reactivity`, such as [SvelteMap](svelte-reactivity#SvelteMap).

@paoloricciuti
Copy link
Member Author

I think I fixed almost everything...there's one bug where if you remove an element from the array and push back to it again it invoke the onchange twice the first time but the rest should work.

However wants to try and break this is well accepted

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

Successfully merging this pull request may close these issues.

$state mutation callback