feat: allow `$derived($state())` for deep reactive deriveds
This needs discussion, but Dominic and I had this idea. Deriveds are not deeply reactive, but even nowadays you can kinda achieve deep reactivity for deriveds by doing
let local_arr = $derived.by(()=>{
let _ = $state(arr);
return _;
});
This is fine, but it's a bit boilerplatey. This PR is to allow a shorthand of this by allowing $state to be used in the init of a derived.
let local_arr = $derived($state(arr));
This basically gets compiled to
let local_arr = $.derived(() => $.get($.state($.proxy($$props.arr))));
So it works exactly the same.
Point of discussions:
- This is a solution, but it feels a bit weird...shouldn't this be a new rune instead? (I don't think we should add a new rune...just trying to think aloud)
- While testing this, I've discovered a quirk of this approach (which was obvious in hindsight): since when you proxify a proxy, we just return the original proxy when the prop is
$stateand not$state.rawyou are actually mutating the parent
<script>
import Component from './Component.svelte';
let arr = $state([1, 2, 3]);
</script>
{arr}
<Component {arr}/>
<script>
let { arr } = $props();
let local_arr = $derived($state(arr));
</script>
<!-- this actually push to the parent state --!>
<button onclick={()=>{
local_arr.push(local_arr.length + 1);
}}>push</button>
However, this is also true for the original trick that we currently "kinda" recommend.
I look at the server output, and it doesn't need any changes, since $state is just removed anyway.
WDYT should we do this?
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
- [x] Prefix your PR title with
feat:,fix:,chore:, ordocs:. - [x] This message body should clearly illustrate what problems it solves.
- [x] Ideally, include a test that fails without this PR but passes with it.
- [x] If this PR changes code within
packages/svelte/src, add a changeset (npx changeset).
Tests and linting
- [x] Run the tests with
pnpm testand lint the project withpnpm lint
🦋 Changeset detected
Latest commit: e846ebb5785075dbe796d90758d58551a55da50b
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
YES. There must have been hundreds of Discord questions and Github issues related to this. To avoid the parent mutation problem, currently I'm doing it like this:
let local_arr = $derived.by(()=>{
let _ = $state($state.snapshot(arr));
return _;
});
Which translates to
let local_arr = $derived($state($state.snapshot(arr)));
It's up to debate whether this should be the default behavior
There must have been hundreds of Discord questions and Github issues related to this.
I've been collecting.
- https://github.com/sveltejs/kit/issues/13626
- https://github.com/sveltejs/svelte/issues/14536
- https://github.com/sveltejs/svelte/issues/15164
- https://github.com/sveltejs/svelte/issues/15909
- https://github.com/sveltejs/svelte/issues/16189
- https://github.com/sveltejs/svelte/issues/16523
- https://github.com/sveltejs/svelte/issues/16804
- https://github.com/sveltejs/svelte/issues/16887
- https://github.com/sveltejs/svelte/issues/17289
Alternatively, we could expose a proxify function (name TBD) from the Svelte runtime, which wouldn't require any compiler changes. You would then do $derived(proxify(whatever)) when you need a mutable reactive derived instead of the $derived($state(whatever)) currently proposed.
This would also have the advantage of working in a universal load function in a +page.js (where you can't use runes), providing you with data props that you can mutate anywhere in your app and have them be reflected everywhere else.
Alternatively, we could expose a
proxifyfunction (name TBD) from the Svelte runtime.
I have been doing this for months in userland by exporting functions that wrap $state and/or $state + $derived.by from .svelte.ts files. It started because I wanted to create $state on load functions, which I found extremely useful to separate app state (now resides on load functions) from UI state, event handlers, CSS and markup (in .svelte files). Having this built-in and officially supported would be awesome!
This would be most helpful. After the recent change to warn state_referenced_locally on $state(prop), something like this would be very helpful to achieve a common pattern in our app: creating a new reactive state from an incoming prop.
In terms of naming/conventions, I've also seen the suggestion in some other issues for $state.from(prop), which would operate like $derived($state(prop)). It could also support the $derived.by style of supplying a function. e.g.
<script>
const { client } = $props()
let cats = $state.from(() => {
client.cats.map((cat) => ({
...cat,
_id: Math.random(),
})),
})
function addCat() {
cats.push({
_id: Math.random(),
})
}
</script>
{#each cats as cat (cat._id)}
<input type="text" value="{cat.name}" /><br />
{/each}
<button onclick={addCat} title="Add Cat">Add Cat</button>
In the above, if the client prop changed, any $state.from would get recalculated
Pretty sure any version where $state is the leading text is wrong. $state denotes a source. Which this is not, this derives it's source.
My preference would be $derived.state or $derived.proxy to clearly describe that, unlike a regular derived, this has similar properties to a deeply reactive $state proxy with dependency tracking.
But I'm also fine with $derived($state(prop))
One question with this approach would also be reassignments.
let thing = $derived($state(array));
thing = otherArray;
Is thing still reactive? I suspect not.
So having a $dervide.xxx() rune that takes care of the state wrapping internally might be better. The function approach should also work.
thing = $state(otherArray); // not allowed
thing = proxify(otherArray); // OK
The proxify fn seems better than overloading the terms state or derive. It's also more honest about what is happening.
function proxify(value) {
const proxified = $state(value);
return proxified;
}
let foo = $derived(proxify(...));
src: https://github.com/sveltejs/svelte/issues/16189#issuecomment-2979989750
One question with this approach would also be reassignments.
let thing = $derived($state(array)); thing = otherArray;Is
thingstill reactive? I suspect not.
If you reassign to the derived, it will break reactivity, yes. If you reassign to the prop/state it's based on it will work correctly.
https://svelte.dev/playground/1a0230ade38a4ddd8ebc64ecfa8c5d95?version=5.45.6
This foot gun would exist regardless of whether you use $derived($state(thing)) or $derived(proxify(thing)). Svelte might need another warning if you attempt to reassign to a proxified derived.
To avoid reassignment:
function box<T>(value: T): { current: T } {
const boxed = $state({
current: $state.snapshot(value)
});
return boxed;
}
const thing = $derived(box(array));
thing.current = otherArray;
This foot gun would exist regardless of whether you use
$derived($state(thing))or$derived(proxify(thing)).
The difference is that in one case you can avoid the reactivity loss using the same syntax (proxify(thing)) and in the other you cannot because the syntax is not allowed (using $state on already declared variable).
And as noted, if the wrapping happens inside a dedicated rune, the problem would not exist at all.
I think it's possible to make $derived($state(...)) or $derived.xxx work with reassignment without using a box. That would be an improvement from the current $derived.by(...) and a justification for a new rune/syntax change. Need more compiler magic!
This foot gun would exist regardless of whether you use
$derived($state(thing))or$derived(proxify(thing)).The difference is that in one case you can avoid the reactivity loss using the same syntax (
proxify(thing)) and in the other you cannot because the syntax is not allowed (using$stateon already declared variable).
That still results in reactivity loss.
https://svelte.dev/playground/663f9bf9fe4e420c8b398b6b83256d16?version=5.45.6
To avoid reassignment:
function box<T>(value: T): { current: T } { const boxed = $state({ current: value }); return boxed; } const thing = $derived(box(array)); thing.current = otherArray;
That does more intuitively help you avoid the foot gun. Though the foot gun still exists if you reassign to thing. However, it's at least mitigated because you're far more likely to reassign to .current.
If not making a new rune, this is the method I'd probably go with. Otherwise, as stated, you'd probably need another warning.
Sorry if this question makes you smile (I'm the least appropriate person to answer these technical questions): why can't we have a deep reactive $derived() like $state()?
My assumption is that $derived is not proxified by default, because it's less performant. Same reason $state.raw exists.
For minimal magic-guts-exposing (exposing the behavior without lifting the nuts-and-bolts of how reactivity proxies work right to the user's face), maybe some extensions on derived would serve well?
const thing = $derived.proxy(array);
const thing = $derived.proxyOf(() => {
// if possible
return array
});
That would satisfy:
- Having a discoverable function ("hm, what's thing on $derived?")
- Have it be $derived (not $state)
- Naming it to encourage deeper learning of the system ("proxy?")
- Without requiring knowledge of that system to reason about at the surface level ("of course $derived($state(array)) works because $state() transforms array into a proxy with deep reactivity, and $derived ensures the toplevel state is recreated when array changes" -- did I even get that right?)
Edit: not as a replacement for what this does, but an extension.
Having a discoverable function ("hm, what's thing on $derived?")
This is the most compelling argument I've seen for a new rune. $derived($state(...)) is still kind of obscure
I'm not sure proxy/proxify functions are the best approach. They expose terms that ideally should only exist within Svelte internals. And the use of $derived wrapping a $state sounds problematic because of the reassignment issue. So perhaps a new rune (or variation of existing) is the best path forward, one that returns a $state proxy, not a $derived one.
Some options being tossed around + some new ones:
-
$state.from(prop) -
$state.derivedFrom(prop)(like above but more explicit as to purpose) -
$derived.state(prop) -
$derived(prop).toState()(convert from derived proxy to state proxy)
My personal preference is for options 1 or 3, but given how often I need this in our codebase, I'd be happy with any of them :-)
Another alternative would be to allow a way to make $derived deeply reactive (substitute deep for whatever):
-
$derived.deep(prop) -
$derived(prop, { deep: true }) -
$derived(prop).deeplyReactive()
A significant argument against a rune in my view is the inability to use it in a SvelteKit load functions.
The name proxify does make me uncomfortable because it exposes an implementation detail. We might need to first come up with a good name for reactive objects themselves before we can name the function that produces them.
A significant argument against a rune in my view is the inability to use it in a SvelteKit
loadfunctions.
This PR/discussion is about making deeply reactive $derived simpler. Either I'm misunderstanding something or I'm not sure why the inability to use it in a load function matters since you can't use $derived there either.
As for the name, Svelte doesn't really hide that it uses proxies as an implementation detail, but perhaps $derived(deep(thing)) works.
Personally I really like $derived($state(thing)). It's simple and easy to understand and doesn't expose implementation details. There's still a reassignment issue, but it's fine if you don't do that.
When coming up with a new API, all cases needs to be considered, not just the current one we opened an issue for. This is what Rich does and why we enjoy it. The buttery smooth vibes.
My attempt at naming:
$derived(deeply(foo))
A significant argument against a rune in my view is the inability to use it in a SvelteKit
loadfunctions.
Why do you even need to use this feature in the load function? I think this is a client side UI thing only. I can understand when you want to proxify things in the load function but a wrapper function returning a $state should suffice.
My arguments against supporting the load function:
- Not sure about usage, as above.
- Load function is not the future, remote functions are.
- You can have both: a new rune and a wrapper function to make it compatible with the load function, if you really really need (I think not).
- More powerful tools already exist if you really want to use runes in the load function - reactive classes, in combination with transport hook.
A new rune is better than any wrapper solution because:
- Discoverability - as mentioned above.
- It can solve the reassignment problem with the help of compiler magic (similar to what's currently implemented for $state), avoiding the footgun
As for naming, I vote for either $derived.deep, $derived.proxy or $state.link (cough cough).
$derived.state is kind of confusin.
I'm not sure
proxy/proxifyfunctions are the best approach. They expose terms that ideally should only exist within Svelte internals. And the use of$derivedwrapping a$statesounds problematic because of the reassignment issue. So perhaps a new rune (or variation of existing) is the best path forward, one that returns a $state proxy, not a $derived one.Some options being tossed around + some new ones:
$state.from(prop)$state.derivedFrom(prop)(like above but more explicit as to purpose)$derived.state(prop)$derived(prop).toState()(convert from derived proxy to state proxy)My personal preference is for options 1 or 3, but given how often I need this in our codebase, I'd be happy with any of them :-)
Another alternative would be to allow a way to make $derived deeply reactive (substitute
deepfor whatever):
$derived.deep(prop)$derived(prop, { deep: true })$derived(prop).deeplyReactive()
+1 for $derived.deep. Pretty much explains itself and follows the same convention as writing $derived.by
To avoid reassignment:
function box<T>(value: T): { current: T } { const boxed = $state({ current: $state.snapshot(value) }); return boxed; } const thing = $derived(box(array)); thing.current = otherArray;
I like this. If this doesn't get implemented, it could be an idea to run by the Runed library.
I like this. If this doesn't get implemented, it could be an idea to run by the Runed library.
Kinda already existing though. You can check out various box versions here. bits-ui uses them extensively. Personally I'm not a fan of boxes so I tend to avoid them if not absolutely necessary.
https://github.com/huntabyte/svelte-toolbelt
@paoloricciuti As mentioned in some of the above comments, reactivity is lost when reassigned. I think any PR to fix this should make sure reactivity isn't lost when it is reassigned. So it might pay to add/modify a test case to your PR:
<script>
let { arr } = $props();
let local_arr = $derived($state(arr));
function add() {
local_arr.push(local_arr.length + 1);
}
function pop() {
local_arr = local_arr.slice(0, -1)
}
</script>
<button onclick={add}>Add</button>
<button onclick={pop}>Pop</button>
{#each local_arr as item}
<p>{item}</p>
{/each}
If you click "Add", it works, and the first time you click "Pop" it will also work, but then using "Add" after that is now broken, because the "Pop" lost the $state aspect and became a shallow derived.
It's a simple example (obviously the above could just use local_arr.pop, which would not reassign), but in production code is is often more complication (e.g. an object that gets initial props on page load, and certain actions either mutate it or completely replace it). We have been trying to fix up all the new warnings in our production codebase (just shy of 79000 lines of Svelte code), but some of them can't be fixed without something like $derived.deep or some equiv.
Would be great to have some Svelte core-team members weigh in on this. I know they are all very busy on the async functions for Svelte 6. But it would be great to know if we missing something obvious that would allow us to use props in $state or an existing way to make $derived deeply reactive. If not, is there any interest in making something like $dervied.deep possible? And if not, what is the official recommendation for this use case?
I mean if it is working with state just ignore the warning. Personally I would check if it is really working or if there's some underlying bug which this warning just highlighted.
If the latter is true I would try to figure out why do you need this behavior...it doesn't seem super common to me and if you have some realistic example I could maybe help you in that case. I feel like this is not a desirable behaviour most of the times (which is partially the reason deriveds are not deep in the first place)