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 50 commits into
base: main
Choose a base branch
from
Open

feat: add onchange option to $state #15069

wants to merge 50 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

Sorry, something went wrong.

Copy link

changeset-bot bot commented Jan 20, 2025

🦋 Changeset detected

Latest commit: 714c042

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 and others added 4 commits January 21, 2025 13:29

Verified

This commit was created on github.com and signed with GitHub’s verified signature.
* only call onchange callbacks once per array mutation

* fix

* fix

Verified

This commit was created on github.com and signed with GitHub’s verified signature.
@Rich-Harris
Copy link
Member

was in the branch to merge main, figured I'd take the liberty of removing the 'must be an object literal' restriction while I was at it

@paoloricciuti
Copy link
Member Author

was in the branch to merge main, figured I'd take the liberty of removing the 'must be an object literal' restriction while I was at it

Great, I'll see if I can split the tests today

Rich-Harris and others added 2 commits March 21, 2025 09:56
@paoloricciuti
Copy link
Member Author

@Rich-Harris splitted the tests up

Verified

This commit was created on github.com and signed with GitHub’s verified signature.
…ssign-proxy/main.svelte
…vent with the subsequent assertion
Rich-Harris and others added 4 commits March 21, 2025 15:38

Verified

This commit was created on github.com and signed with GitHub’s verified signature.
* WIP

* extract onchange callbacks

* const

* tweak

* docs

* fix: unwrap args in case of spread

* fix: revert unwrap args in case of spread

---------

Co-authored-by: paoloricciuti <ricciutipaolo@gmail.com>
@paoloricciuti
Copy link
Member Author

Any thought to doing something like let foo = $state(watch({ bar :"value" }, () => handleChange()))

Instead of

let foo = $state({ bar :"value" }, () => handleChange())

In my opinion this is more composable and extendable. I know this is an older PR but to me the proposed API feels a bit off.

What's the value of the watch function?

@rChaoz
Copy link
Contributor

rChaoz commented Mar 26, 2025

Any thought to doing something like let foo = $state(watch({ bar :"value" }, () => handleChange()))

I think the issue here is, this makes it seem like watch can be used without $state. Correct me if I'm wrong, but isn't the onchange functionality provided by $state wrapping the object deeply with a proxy? How could watch work without this?

@paoloricciuti
Copy link
Member Author

Any thought to doing something like let foo = $state(watch({ bar :"value" }, () => handleChange()))

I think the issue here is, this makes it seem like watch can be used without $state. Correct me if I'm wrong, but isn't the onchange functionality provided by $state wrapping the object deeply with a proxy? How could watch work without this?

Yeah watch without $state would just be a worse effect.

I mean we could make it work but I just don't understand what the advantage would be (it also looks very ugly)

@paoloricciuti
Copy link
Member Author

Watch would likely wrap the value in a proxy, this is basically a copy of a function I wrote to solve this problem before this PR. It could also be done at compile time but I think you lose some of the extensibility. Watch is also a place holder name it would be “track” but to me if someone comes to this file with watch it’s fairly obvious what the callback it doing. If I see a second arg in the state rune it’s much less obvious what the callback is for.

What this PR does it's different from using effect since it's invoking the callback synchronously... it's also doing it deeply without the need to create a source for the whole object.

@paoloricciuti
Copy link
Member Author

What's not clear about an object with onchange property? Doesn't the onchange signal that you want that callback to be invoked when the state value changes?

@paoloricciuti
Copy link
Member Author

Nope, it's just the compiler that is unwrapping the object to pass just the onchange to the function so the API is still the same

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