-
-
Notifications
You must be signed in to change notification settings - Fork 4.5k
feat: add onchange
option to $state
#15069
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
base: main
Are you sure you want to change the base?
Conversation
🦋 Changeset detectedLatest commit: cfd8ef2 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
preview: https://svelte-dev-git-preview-svelte-15069-svelte.vercel.app/ this is an automated message |
|
Would there also be an |
Yes (uh I need to update types for that too) |
I like this, but also I'm thinking that there are more cases where you get a |
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. |
This would have helped me several times! Figuring out how to watch deep updates was very unintuitive to me. |
Recapping discussion elsewhere — I think we made a mistake with the implementation of 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 svelte/packages/svelte/src/internal/client/proxy.js Lines 32 to 35 in de94159
It would have been better if existing proxies passed directly to 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 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 |
I think it'd probably be best if |
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 let c = $state(b, {
onchange() {...}
}); let c = $state({}, {
onchange() {...}
});
c = b; |
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 let items = $state([...]);
let selected = $state.link(items[0]);
function reset_selected() {
selected.count = 0;
} |
Not sure how I feel about this. Shouldn't this just be some kind of effect, that watches deeply?
|
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 Opened #15073 to address an issue with array mutations causing |
* only call onchange callbacks once per array mutation * fix * fix
* 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 <[email protected]>
What's the value of the |
I think the issue here is, this makes it seem like |
Yeah watch without I mean we could make it work but I just don't understand what the advantage would be (it also looks very ugly) |
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. |
What's not clear about an object with |
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 |
@jjones315 Please don't delete comments from threads, it makes it very difficult for people to understand what (for example) other people are replying to. This repo is a public record. We'll hide irrelevant comments if that's necessary to declutter the thread |
This is good but I'm very concerned about the cost of creating a new set and a new wrapper function on every new proxy. I think we have to come up with a way around that. Here's what I'm thinking: a top-level source can have an It's an array rather than a set because you could have duplicates: function onchange() {
console.log('changed');
}
let proxy = $state({}, { onchange });
let other_proxy = $state({}, { onchange });
// `other_proxy` inherits `onchange` from `proxy`
proxy.property = other_proxy;
// `onchange` is removed from `other_proxy`
delete proxy.property; If it was a set, the add/remove would be assymetrical, and There is a caveat to this approach: let proxy = $state({}, { onchange: foo });
let other_proxy = $state({}, { onchange: bar });
let middle = { other_proxy };
proxy.middle = middle; Here, we wouldn't want to pass As a result, Will hack on this |
Let me know if you want to pair on this or if I can do anything to make your work easier 🤟🏻 |
Gah I made some progress on this (compare/state-onchange...state-onchange-roots?expand=1) but there's a (obvious-in-hindsight) fatal flaw: let items = $state([...], { onchange });
const last = items.pop();
// should not trigger `onchange` as it's no longer in `items`, but it does
last.foo = 'blah'; If the Think I need to step away from this PR for a bit because there are other demands on my attention, but a sketch of a possible alternative approach:
In other words we introduce some new constraints to enable a memory-friendly implementation, but these constraints only apply to state that uses |
I think this was basically my original implementation...I don't think a lot of people will pass proxies to proxies but I wonder if this limitation could be confusing. What if we stick to an array (not a set) and we dedupe the functions when calling them? |
I think that would be a dealbreaker. Consider this situation. <script>
import { localStorage } from './somewhere';
let todo = $state({});
function save() {
// localStorage uses onchange, so this will throw
localStorage.state.todos.push(user);
}
</script>
.... I think something like this is prone to happen one way or the other. |
Yeah I think it would be very confusing or annoying...is there a way to measure the memory footprint? |
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 extraconst
orprivate_id
. We could also pass a parameter toset
to specify if we should proxify or not and that could solve the issue ofget_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 thisis compiled toif 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 thisthe same is true for classes...there however i've used an extra private identifier to store the optionsis compiled tothere'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
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
feat:
,fix:
,chore:
, ordocs:
.packages/svelte/src
, add a changeset (npx changeset
).Tests and linting
pnpm test
and lint the project withpnpm lint