svelte
svelte copied to clipboard
Reactive Map, Set and Date
Describe the problem
There's a TODO in the codebase for making reactive maps and sets:
https://github.com/sveltejs/svelte/blob/434a58711f7ebb2c3849545542b4394d295ccea9/packages/svelte/src/internal/client/proxy/proxy.js#L44-L54
We could implement it, but there are several problems:
- we'd need to add the logic for making reactive map/set wrappers in a way that would be impossible to treeshake away for people who weren't using them
- that cost would increase if we wanted to add support for other built-ins (such as
Date
, and possiblyWeakSet
andWeakMap
) — including future additions to the language - adding support for other built-ins couldn't be done without a breaking change necessitating a major version bump
- it wouldn't be totally clear which non-POJOs get the reactive treatment
- there's also no particularly good opt-out mechanism
Describe the proposed solution
Instead, we could just create our own wrappers:
<script>
import { Map } from 'svelte/reactivity';
const map = new Map();
map.set('message', 'hello');
function update_message() {
map.set('message', 'goodbye');
}
</script>
<p>{map.get('message')}</p>
Importance
nice to have
This seems weird for several reasons.
- Which non-POJOs are special?
- What API do they implement?
- When the spec changes, do you retroactively add support for old svelte versions?
- Do you provide polyfills for new methods (like groupBy, intersection, difference)?
- Will "Svelte Sets" have their own API that's a superset of "JavaScript Sets"?
- How do you guarantee that a "Svelte Set" and a "JavaScript Set" are roughly semantically equivalent?
- Why would these objects not use
$state
? Do I just have to check the every file to see if someone messed with maps and sets to reason about an app?
This just kind of feels like a mix of redefining builtins and providing a small lodash, both of which feel bad. It's also kind of a step back to default reactivity (let
in Svelte 4), but requires an import. Why not get rid of $state
and export Number, String, Boolean, etc. from 'svelte/reactivity'? If $state
is required, why would the import be needed?
The implementation will extend the original classes and defer to them as much as possible. There will not be any additional API on top. The import is needed because else it's not treeshakeable. As pointed out above, the proxy implementation would need to check at runtime if the thing given to it is a Map/Set/whatever and make it reactive on the fly. An explicit import doesn't have this drawback and at the same time makes it very clear that anything non-POJO isn't reactive by default (and you can use your own wrappers or the ones provided by Svelte other stuff)
Thanks for the reply!
Another oddity I didn't think of until now is that this will also mean that if you import svelte/reactivity
, is runes mode enabled? I believe there were some previous instances of things working in svelte 5 with runes mode disabled but breaking with it enabled - although maybe these were just bugs. Not super important, and just explicitly stating whether or not this enables runes mode would suffice.
I'm still not sure I understand why this wrapper needs to be involved in user code, or why the current use of $state
for objects and arrays is necessary. Does treeshaking happen before Svelte wraps reactive variables? As in, could Svelte not just rewrite let map = $state(new Map())
to import { Map } from 'svelte/reactivity'; let map = new Map()
before treeshaking happens?
Also related to the proxy vs import, why isn't this method used for Object
and Array
as well? Should Object
and Array
just not be used for reactivity and this imported Map
wrapper be the default that people use?
Making a wrapper (with classes) is technically what I'm doing until the Map
/ Set
reactivity comes out, it doesn't bother me personally, it's a good solution. That said, it feels a little bit inconsistent with the current Array
/ Object
behavior.
Also related to the proxy vs import, why isn't this method used for Object and Array as well? Should Object and Array just not be used for reactivity and this imported Map wrapper be the default that people use?
Object and arrays can be created using literals (and more often than not are) If an import would be necessary for those, that means you would need to make an array like this:
import { Array } from 'svelte/reactivity'
const myArr = new Array('a', 'b', 'c', 'd')
// instead of const myArray = $state(['a', 'b', 'c', 'd'])
or, in the edge case that you happen to want to initialise the array with a single number, the syntax will get quite ugly:
import { Array } from 'svelte/reactivity'
const myArr = new Array(1).fill(3)
// instead of const myArray = $state([3])
Objects will probably become even worse. Assuming the relevant static methods have been (re)implemented in the reactive version, a simple object literal would become something like this. Note that you won't have any typescript support at all in this case:
// At the moment:
// const myObj = $state({ a: 1, b: 2, c: 3 })
// With wrappers:
import { Object } from 'svelte/reactivity'
const myObj = Object.fromEntries([['a', 1], ['b', 2], ['c', 3]])
// Or
// const myObj = new Object()
// myObj.a = 1
// myObj.b = 2
// myObj.c = 3
The main difference between POJOs and Set/Map/Date is that the latter don't have a literal way to write them, but they require a constructor to make instances of those classes. The way to make those reactive would just be an import statement.
I don't think it would be possible to convince Javascript that the object and array literal would need to create instances of other classes than the built in Array and Object class
The object example isn't very fair, since it would just be:
import { Object } from 'svelte/reactivity'
let myObj = new Object({a: 1, b: 2, c: 3})
For arrays, it's only odd if someone wants to make an array of example one number. This is a pretty specific situation.
Regardless, the point isn't so much "is wrapping Object
and Array
a good idea" as it is "why is wrapping Map
/Set
/Date
/etc a good idea." If the benefit is code size and performance, why not apply the same thing to Object
and Array
? To me, the wrapper is bad for developer productivity and experience, because currently all I have to keep track of is where I put $state
/$derived
. I don't have to think "did I use a JS Map, a Svelte Map, a non-reactive Object, or a $state({})
for this thing?
I hope that makes my point clear. I don't think wrapping Object
and Array
is a good idea. For the same reason, I don't think wrapping Map
, Set
, etc. is a good idea.
I don’t have much technical say in this matter but not gonna lie it does kinda feel like this goes against the “vibe” so far
Would you expect something like let x = $state(new MyCustomObject())
where MyCustomObject
doesn't have any Svelte-specific stuff (no runes) in it, to magically update references of x.someProperty
if I do x.updateSomeProperty()
? You wouldn't, because how would Svelte be able to know how things are connected.
In the same way, if you write let x = new MyRunesObject()
and you know that x.someProperty
is $state()
you would expect it to be reactive when something changes that property - without having to wrap the whole thing with $state()
at the declaration site of x
.
Now, if you think of Map
etc as being one of these custom classes instead of a built-in, it all makes sense why you need a) a specific wrapper and b) don't need to write $state()
when using it. It's also very easy to explain documentation-wise: POJOs inside $state()
are made reactive for you, anything else needs treatment by you to make that thing reactive.
if you import
svelte/reactivity
, is runes mode enabled?
No, they're completely orthogonal
could Svelte not just rewrite
let map = $state(new Map())
toimport { Map } from 'svelte/reactivity'; let map = new Map()
before treeshaking happens?
Sure. Now do this :)
import { get_map } from './somewhere.js';
let map = $state(get_map());
I can understand both sides: svelte devs want a straightforward elegant interface, while the maintainers also have to wrangle the technical feasibility and need of a general approach.
I found the exchange very interesting so far and after pondering all arguments, I'd like to make a proposal as well:
Allow a second declarative configuration object on the $state rune to define additional reactivity
$state( obj, config = { addReactivity: ["Set", "Map"] } )
✅ Declarative: opt-in config We declare the reactive behaviour directly on the $state (where you would expect it to be) in a controlled and opt-in fashion. Plain Object/Array reactivity are on by default, everything else needs to be enabled. How does this compare to wrappers? With wrappers, a developer would need to enable reactivity by having to import a same-but-not-same "magic object" and also use it in the state.
✅ Safely Tree-Shakable The compiler can ingest the config object to assess which proxied build-ins are needed and discard the rest
✅ Expandable at will (no breaking changes & no major versions changes required) Since we have declared our reactivity in a controlled and opt-in way, future versions of svelte can safely add new build-in proxies, which devs can intentionally decide to enable or not for a $state. The default behaviour persists, we do not need to fear breaking changes.
✅ Sweet Sweet Svelteness Aligns with Svelte's Design Philosophy of using native APIs and declarative programming. Yes, we can maintain the sweet svelte taste we have acquired: write pure native js, svelte does the rest - not magic, but magical :P.
✅ Self-Explanatory / IDE-hints The $state's signature/JSDoc is enough to hint at the additional configuration options; devs do not need to wander the docs searching for how to setup the behaviour they want correctly.
✅ Party-Friendly 🎉
Assume the following scenario: a 3rd party library (or just some stray function) adds/changes our state object - e.g. by setting a property using: set = new Set ( [...set, ...newItems] )
.
With wrappers, the state's reactivity breaks since the 3rd party lib does not know about wrappers and uses the standard build-ins. With the declarative state approach and proxied build-ins, the state continues to behave as expected.
Last but not least: I'm merely a humble, new student of svelte's internal workings, so hopefully this proposal may be valid and of use.
This still can give issues with the example Rich gave before:
// somewhere.js
const myMap = new Map()
let count = 1
myMap.set('foo', count++)
myMap.set('bar', count++)
export function get_map() {
return myMap
}
export function add_to_map(key) {
myMap.set(key, count++)
}
<script>
// myApp.svelte
import { get_map, add_to_map } from './somewhere.js'
const map = $state(get_map(), { addReactivity: ["Map"] })
</script>
<button onclick={() => add_to_map('button pressed')}>Size: {map.size}</button>
Whenever myApp gets used, all of the sudden the existing map would need to be changed to make it reactive If it is part of an existing library, that will likely give some sort of issues somehow
Yeah an options argument would not work as expected in all cases because of reasons like this. Also, I personally would rather import the wrapper explictily compared to adding a second options argument where I end up typing more in the end (autocomplete will give me the wrapper import for free). Regarding the wrapper: We will have to write one either way, the API doesn't change this fact in any way.
Can I make a PR for Rich's proposal? I want to get more familiar with the internals and this seems like a pretty good opportunity
I appreciate the feedback, but can't share your reservations: Rich's example (Map dynamically added during runtime) should be adequately covered by the proposed declarative opt-in config object, as it would be proxied just like Arrays and Objects are right now.
Here's how it works:
- The dev defines the expected behaviour
import { get_map } from './somewhere.js';
let map = $state(get_map(), { addReactivity: ["Map"] } );
- Now the compiler knows that the proxied built-in "Map" is needed and packs it.
- When the
$state
is modified during runtime, the proxy function checks the object's prototype and proxies theMap
, just like it does forArrays
orObjects
that are dynamically added to $state.
@dummdidumm
Regarding the wrapper: We will have to write one either way, the API doesn't change this fact in any way.
Hm what for? If Map
, Set
, Date
, ...
can be proxied like Array
and Object
are right now, what would you need a wrapper for? (unless you call a proxy a wrapper?)
Re: "type more": That's rather subjective. Keep in mind that manually adding a config
is only needed for objects, while for simple setups (eg $state( new Set() )
), the compiler can automatically add the correct config object. PS: there are many more ways the interface can be optimized for length, but the key for a great DX/UX is consistency.
(autocomplete will give me the wrapper import for free).
Well, autocomplete will also give you the properties for the config
object. Even more: it gives you all you need to know directly associated to the $state
rune in its IDE hover/jsDoc/signature
as it's - not just semantically - but also directly syntactically connected
. The first place a dev would look for info on the $state
behaviour is the $state
.
(btw: I also like the config object as it's extendable; at some point we might want to add other options, eg reactivityDepth)
@MotionlessTrain
Whenever myApp gets used, all of the sudden the existing map would need to be changed to make it reactive
In your example, is myApp a component that is used multiple times through the app and you want the same state used? In that case, wouldn't you need to import myMap from a svelte.js
file that contains export const myMap = new Map()
? If not, please clarify. Also, are you sure your example would work as a POJO either (REPL?
If it is part of an existing library, that will likely give some sort of issues somehow
Unlike the config object solution
that proxies built-ins
, the wrappers approach would certainly break reactivity whenever a library adds a Set/Map property to a $state
(or does modifications using set = new Set( [ ... ] )), because, as explained above, libraries would use the standard built-ins and not the special svelte wrappers (to avoid this, each library would need to know about those wrappers and explicitly call them in their source code).
Summarizing (IMO)
-
declarative config
preserves svelte's philosophy: native js and declarative programming, allows to continue proxying new built-ins in the same way as Object and Array are proxied & is extendable without breaking changes -
wrappers
: would put svelte back into (black) magic / special knowledge territory
It'd be also interesting to hear from other devs who previously stated dissatisfaction having to use new wrappers to replace standard JS built-ins on the dev side ("the vibe is off") like @Antonio-Bennett @harrisi and others who liked their posts.
Now the compiler knows that the proxied built-in "Map" is needed and packs it.
This may work when you wrote the get_map function yourself, and it returns a new empty map, but if it is exported from a library (and e.g. turns out to be a sub class or proxy of Map already), it can't just be changed by the svelte compiler without giving all sorts of issues with that library
In your example, is myApp a component that is used multiple times through the app and you want the same state used? In that case, wouldn't you need to import myMap from a svelte.js file that contains export const myMap = new Map()?
I was extending Rich's example, pretending that somewhere.js could be part of any JavaScript library, and you don't know what it looks like. I admit that this would be a silly implementation, but if it would automatically be able to proxy Maps by using $state, it should be able to handle those cases as well (not just the much easier function export const get_map = () => new Map()
, for which the $state approach would be much less of an issue)
Afaik it doesn't matter whether the map would be defined in a .js or a .svelte.js file in this case
In fact, I don't think this would work with POJOs (as the proxy is only made in the svelte component, not in the somewhere library), which would make the behaviour as you expect it to be even weirder. Why would the map returned by get_map get proxified by the svelte compiler (which would mean that somewhere.js would have the proxified Map instead of the built in one), whereas POJOs returned by get_object would not be proxified inside of the library function?
It seems like you're conflating different concepts. Primarily, you might be confusing this with Harrisi's suggestion that the compiler should add the wrapper
during the compilation step itself, which has nothing to do with the config object approach.
(and e.g. turns out to be a sub class or proxy of Map already)
- Sub/Custom classes are already excluded from reactivity, so this falls in line with expected behaviour.
- additionally, ECMAScript's Proxy can be applied multiple times to an object without issues. No that it matters, since we are not manipulating internals of third-party libraries (contrary to what you might have assumed).
, it can't just be changed by the svelte compiler without giving all sorts of issues with that library
The Svelte compiler won't change it at all, nor was it ever intended to. That's the whole point of the proposed config object
approach: we utilize the same proxying pipeline as for objects/arrays, which operates during runtime and can dynamically and transparently proxy new objects, whether they originate from 1st or 3rd-party code.
(which would mean that somewhere.js would have the proxified Map instead of the built in one), whereas POJOs returned by get_object would not be proxified inside of the library function?
No, that's just incorrect and not how svelte's $state works. (I admit it also tripped me up a bit when first examining svelte 5).
$state returns the proxy object based on the $state(init value
). So your hypothetical library's 'somewhere.js' internally never uses svelte's proxied object, not for the proposed config object
approach nor for proxied built-ins under the current code.
What the proposed config object
approach ensures is that if a $state in your app is modified
by a 3rd party library, it maintains reactivity for built-ins like Map, Set, Date, etc.
Afaik it doesn't matter whether the map would be defined in a .js or a .svelte.js file in this case
Well, in your example, if you expect the state to be shared among your components (and not passed via props, et.), then yes, it must be imported from a .svelte(.js) file, as $state is a compiler instruction, not a runtime function. (The compiler actually transforms it into the proxy function.)
Also, it feels like you're venturing too far into hypothetical territory here, making it difficult to align our assumptions.
If you have any working code/REPLs that demonstrate, for example, a case that actually works with object
under the current implementation but may have issues with Map (and other prospective built-ins to be proxied/made reactive), I'm happy to consider it.
📌
Adding a simple config
object to $state
still appears to be the cleanest & most practical solution that covers all requirements, even more than wrappers can. Plus it doesn't need introducing new paradigms for library users (ie wrapping built-ins) while also avoiding adding complexity into the svelte library itself (as it just uses the same proxying pipeline as object
and array
).
I may have been thinking too much about them being a class I was imagining that svelte would need to change the call to the constructor to a call to a constructor of a proxified Map, instead of the proxy being applied later
That said, I'm wondering how the prototype chain of a proxy works. Would $state(new Map(), { addReactivity: ['Map'] }) instanceof Map
still return true?
Not sure if I agree about the subclasses not being proxified. As I said, a library may specify they return a Map from a function, but return a subclass of a Map instead. In that case, if you would wrap that in a $state somewhere in your own code (with the config parameter to make it reactive), you may expect it to become reactive, which won't happen because it is a subclass.
You still think to much about classes. :) You might want to check the proxy
function on how it is actually done.
That said, I'm wondering how the prototype chain of a proxy works. Would $state(new Map(), { addReactivity: ['Map'] }) instanceof Map still return true?
ECMAScipt's Proxy is made to be transparent and undetectable, so it doesn't have a different constructor.
Not sure if I agree about the subclasses not being proxified.
It's nothing to agree with or not (I'd like custom classes support), but svelte currently doesn't allow for that yet. ( #9739 - Non-Pojo Section ).
As I said, a library may specify they return a Map from a function, but return a subclass of a Map instead
Besides the error being with the bad spec, it would be the same behaviour you would get with $state of Object
and Array
types (again: the proxying pipeline for the config object
approach being exactly the same as before).
Btw, you can easily try out both of these things in the REPL yourself. 1. Make a proxy/$state of an array/object and check instanceof (or get the prototype) 2. try to use a custom class as the value of a $state.
It's nothing to agree with or not (I'd like custom classes support), but svelte currently doesn't allow for that yet. ( https://github.com/sveltejs/svelte/pull/9739 - Non-Pojo Section ).
I was thinking about how the svelte compiler would know whether to proxify the value within $state or not based on the given config.
I was trying to figure that out for arrays, and it either seems to be checking Array.isArray(arr) && arr.__proto__ === Array.prototype
, or it is some completely other check to find out the object is actually an array (for some reason, Array.isArray(new MyArray)
returns true, which surprised me from the MDN documentation of isArray)
The only two ways I could think of was either using instanceof Map
(or Set or Date, etc.), which would allow subclasses, or checking map.__proto__ === Map.prototype
, which would not allow sub classes, but does allow strange objects like { __proto__: Map.prototype }
I barely have time for anything these days, so please excuse any brevity or lack thereof, as well as any potential impoliteness. I'm just trying to provide some constructive criticism :)
Would you expect something like
let x = $state(new MyCustomObject())
whereMyCustomObject
doesn't have any Svelte-specific stuff (no runes) in it, to magically update references ofx.someProperty
if I dox.updateSomeProperty()
? You wouldn't, because how would Svelte be able to know how things are connected.
So, yes, I would. Svelte would know because this is how proxies work, right? You just set up the set
handler like you're already doing for objects and arrays. For some situations with weird classes or objects, this may break, but I think in general you'd have to really try to break it.
In the same way, if you write
let x = new MyRunesObject()
and you know thatx.someProperty
is$state()
you would expect it to be reactive when something changes that property - without having to wrap the whole thing with$state()
at the declaration site ofx
.
I don't see why this wouldn't work as expected when using proxies, but I also don't think I'd come across this often. I'm thinking that if I use a library that defines some class, I'd like to just do let point = $state(new Point(4, 5))
, and when I do point.x++
, anything that's watching point
will see that change.
Now, if you think of
Map
etc as being one of these custom classes instead of a built-in, it all makes sense why you need a) a specific wrapper and b) don't need to write$state()
when using it. It's also very easy to explain documentation-wise: POJOs inside$state()
are made reactive for you, anything else needs treatment by you to make that thing reactive.
This really confuses me. Why would I think of "Map
etc" as being one of these custom classes instead of a built-in? That's like saying "if you think of prime numbers as not numbers, it makes sense why you can't use primes.". Map
is a built-in.
All of this also feels like a regression because Svelte 4 doesn't have this issue. You can see it working with Map
and a custom class Foo
here: https://svelte.dev/repl/fea1e71a27904c9fafbe845ec9365c7b?version=4.2.10. The only odd thing is that you need to do foo = foo
or map = map
to trigger updates, but this is the same as if they were arrays or plain objects. While odd and a bit awkward, it is consistent.
I think that is getting to the core of the issue for me; if this proposal happens, reactivity becomes less consistent. $state(0)
, $state([])
, and import Map from 'svelte/reactivity'; new Map()
are all different, but mostly do the same thing, but also $state(new Foo())
works like $state(0)
in that it's treated like an immutable value. It feels fundamentally confused, and like a potential for really unfortunate bugs. I also expect people will do strange and hacky things to get around this inconsistency, by creating weird Franken-objects with weird prototypes to make Svelte proxy them.
I don't mean to sound combative or anything, and I appreciate the work you all have done. I realize I'm likely fundamentally misunderstanding things and/or just plain wrong. I'm both very tired all the time due to life and not in expert in JavaScript. I'm extremely appreciative that this discussion could even happen, thanks to the public development you all have done.
Indeed, there lies the crucial point: Good DX/UX is all about consistency.
We all applaud the team for doing an amazing job consolidating Svelte 5 so far, taking it out of "magic" territory into idiomatic candyland. But the wrappers proposal is not consistent with anything Svelte did before, nor dev expectations or what lib-users would expect in general - risking to destroy that coherent elegance. Which is unnecessary, as there's at least one better solution.
( I think we agree that the first proposed adjustment - to have the compiler insert the wrapper during build - won't fit, as outlined by Rich, it can't handle dynamically added vars during runtime ).
The $state config object, which we have thoroughly discussed, still holds valid as covering all requirements and, as far as I can tell, fits best with Rich's outlined svelte tenets. So, I see no reason - unless someone comes up with a completely new and better proposal - to not go with the optional config object
approach as it is the most Svelte
-like choice we got.
Of course, it would be much valuable to get Grandmaster Svelte's (@Rich-Harris) assessment on all of this.
I don't see why this wouldn't work as expected when using proxies, but I also don't think I'd come across this often. I'm thinking that if I use a library that defines some class, I'd like to just do let point = $state(new Point(4, 5)), and when I do point.x++, anything that's watching point will see that change.
It's impossible to implement this reliably using proxies. How would the proxy know that date.setHours(10)
does affect date.getTime()
? These are unrelated methods and the proxy does not have access to the underlying logic to figure out what affects what here.
...but also $state(new Foo()) works like $state(0) in that it's treated like an immutable value
This certainly is a gotcha. It probably makes sense to have a check here to treat reassignment of non-POJOs as an update trigger.
Adding a simple config object to $state still appears to be the cleanest & most practical solution that covers all requirements, even more than wrappers can. Plus it doesn't need introducing new paradigms for library users (ie wrapping built-ins) while also avoiding adding complexity into the svelte library itself (as it just uses the same proxying pipeline as object and array).
This isn't true.
- From a DX perspective, it's more characters to type (if you take auto import into account) and complicates the
$state
call - I also find the end result less pleasing to the eye. It isn't consistent with defining your own classes, because they aren't magically wrapped by some definition - unless you somehow add a global mechanism to have custom strings for thatreactivity
option, which makes things even more confusing and brittle. - From an implementation perspective, this doesn't change much. Either we create a special proxy for Map, Set etc which knows how to handle the methods of those classes (i.e. that calling
set
will updatesize
etc), or we create a class that extends Map/Set etc and does the same. Either way, we need special code for each class.
It's impossible to implement this [Date] reliably using proxies.
Agreed, there are some built-ins (like Date) that are blocked from proxying and would need a wrapper. So yes, extra code is needed for those cases. Still, just like with signals, the actual implementation (wrapper) doesn't need to be exposed as a new concept to learn and as an additional responsibility for the developer to manage.
This isn't true. From a DX perspective, it's more characters to type (if you take auto import into account) and complicates the $state call - I also find the end result less pleasing to the eye. It isn't consistent with defining your own classes, because they aren't magically wrapped by some definition - unless you somehow add a global mechanism to have custom strings for that reactivity option, which makes things even more confusing and brittle.
Let's see whether this is true or not by comparing. As previously stated the compiler only needs this additional config object hint
for objects, not one-type values, whereas with exposed wrappers, the developer always needs to remember to import those specific wrappers (with or without autocomplete).
Value | Config Object | Exposed Wrappers |
---|---|---|
Set | $state( new Set() ); | import { Set } from 'svelte/reactivity'; $state( new Set() ); |
Map | $state( new Map() ); | import { Map } from 'svelte/reactivity'; $state( new Map() ); |
Object | $state( { mySet: new Set(), myMap: new Map() }, { addReactivity: ["Map", "Set"] } ); | import { Map, Set } from 'svelte/reactivity'; $state( { mySet: new Set(), myMap: new Map() } ); |
Empty Object | $state( {}, { addReactivity: ["Map", "Set"] } ); | import { Map, Set } from 'svelte/reactivity'; $state( {} ); |
As we can see, only for objects of unknown shape/type - i.e. which haven't been initialized with the values we want reactive - the developer needs to give the compiler this additional config
hint. A config object is a normal pattern for APIs and the usage directly available when hovering $state
in the IDE (or super&click the name).
Granted, once you have declared the import you don't need to write it again for that file. Yet whether a project uses many small components or one big file, whether they use many one-type values or POJOs depends on the project / dev methodology. It's more likely that a project uses smaller components (best practice) and mostly one-type values (more common).
Those examples with auto-configuration feel too magic. E.g. when I see the following code:
let myState = $state({ buffer: new Int32Array() });
I'll ask myself: is myState.buffer
reactive? How can I find out? Do I have to remember and update the list of all built-in classes that Svelte may turn into reactive?
Pardon me (it's been a long discussion), there was a slight oversight in the third row of the example (corrected now).
As outlined in my original proposal > "Expandable at will (without breaking changes)", addReactivity
is opt-in only, so all objects must have stated their reactive built-ins, even object's with initial values. (one-type values can still remain as they are as there's no use for $state( new Set() )
if you didn't want it to be reactive).
The correct opt-in approach for your example gives clarity and controllability at all time, without any unexpected breaking changes (devs need to opt-in explicitly for reactive built-ins, even if a new svelte version adds them). I hope you agree that the "magic is gone" now.
let myState = $state({ buffer: new Int32Array() }, { addReactivity: ["Int32Array"] } );
Thanks for bringing this up! This could actually be a good addition in DEV mode: When a user uses a built-in inside $state() but has addReactivity
not defined, there could be a console: "Do you want Set
reactive? If so, add addReactivity: ["Set"] to $state".
I don't see why this wouldn't work as expected when using proxies, but I also don't think I'd come across this often. I'm thinking that if I use a library that defines some class, I'd like to just do let point = $state(new Point(4, 5)), and when I do point.x++, anything that's watching point will see that change.
It's impossible to implement this reliably using proxies. How would the proxy know that
date.setHours(10)
does affectdate.getTime()
? These are unrelated methods and the proxy does not have access to the underlying logic to figure out what affects what here.
As far as I understand, in general, proxies work great for non-built-in classes and objects. They still work with built-ins, it's just a bit awkward. This would certainly make the implementation a bit more unwieldy, but I don't think by much, and I don't think it will ever need to be changed. Here is an example of a Date
proxy. I think there's a simpler way to do it, but I don't have time to look into it. I also suspect that it's quite close to being able to trick Svelte into working reactively, but I didn't bother to figure it out.
...but also $state(new Foo()) works like $state(0) in that it's treated like an immutable value
This certainly is a gotcha. It probably makes sense to have a check here to treat reassignment of non-POJOs as an update trigger.
If this happened then there wouldn't be a need for the custom svelte/reactivity
Map
wrapper, correct? The only awkward thing is now there's still two separate ways to have reactive objects - the "special" POJO and array, and the rest. I still think it's possible, and very much ideal, if these could be united so the behavior is consistent. This is actually interesting though, since what if I have an immutable object? Then I would want reassignment to trigger an update. But that seems like the different behaviors should be based on mutability, not on what properties an object has. Anyone that was working with immutable objects should understand how that would work.
I also wanted to just throw it out there to try to steer the discussion - I definitely do not think a config option is a good option. I think it has way too many problems and isn't relevant to this issue. Probably best to move that discussion elsewhere (although I don't really think it's worth having at all, honestly).
Let's look at this example:
let myState = $state( {}, { addReactivity: [ "Set"] } );
const set = get_builtin_set();
myState.set = set;
Here, set
is pure build-in Set
. We expect myState.set
to be reactive, with no questions. But now the real questions: are myState.set
and set
the same collections (instances)? If not, this means myState.set
is a copy of set
, and it is confusing - people will trip over it. If yes, that means set
must become reactive after its creation - it is doable, but I don't feel it is the right way and suppose it also may cause trouble.
As for me, adding a general API that may cause altering of the assigned values is weird.
import { Set } from 'svelte/reactivity'; $state( new Set() );
Why would you do let mySet = $state( new Set() );
when you can do more clean let mySet = new Set();
? Working example.
I think in most cases, people will use Map
and Set
as is: without nesting them into another object. However, I cannot say the same about other built-in classes.
About using proxy-wrappers vs derived classes. In both cases, it will require an individual approach for each class - at the very least, you need to read the class' docs to find out which method and properties are causing writing and which are only reading. I believe the proxy approach is shorter, but adding reactivity optimisations, like in the example above, is more messy. On the other hand, I think the derived classes approach is more performant and not so much longer.
There's a flaw in my Date
example, where the Date
needs to be made in the client code. I think it's still possible without the proxied constructor, though. (EDIT: which I realize Rich pointed out weeks ago.)
Also, the idea is that the compiler would add this, it wouldn't be written out like it is in the repl. The weird Date
stuff (checking for set*
calls) is only needed for built-ins using internal slots. For general classes, you only need to watch set
and defineProperty
for mutations. This is how it works for objects now, you can add incFoo()
to your object wrapped in $state
, and it works as expected.
I'm also not hating the idea of going back to "reassignment is reactivity" for all objects (including plain objects and arrays). Easier, consistent, and basically makes everything into a transaction, which I'd never thought of before. But this would mean not making objects and arrays special.
I wanted to share how I've been using reactive lists and dictionaries. Maybe CRUD functions like these could be incorporated into a special constructor for declaring lists and dictionaries:
export const store = $state({
store: {
someString: "",
someList: [],
someDictionary: {}
},
initialize() {
//some logic
},
set(key, value) {
this.store[key] = value;
},
push(key, value) {
this.store[key].push(value);
},
remove(key, value) {
let index = this.store[key].indexOf(value);
if (index !== -1) {
this.store[key].splice(index, 1);
}
},
get(key) {
return this.store[key];
}
})
usage
import {store} from "$lib/store.svelte.js";
store.get('someString')
My 2 cents :
Svelte 5 so far looks like an improvement compared to the existing reactive solutions that are using a .value
wrapper.
But for this part this feels like a step backwards (vue reactivity works with both for example)
As someone who uses Map and Set a lot, I would prefer the non tree-shakeable version over both proposal discussed here.
For people who don't use Map and Set, maybe distributing alternative lighter versions of svelte is an acceptable solution.
Regarding clarity of which objects works with what, I believe it's fine to support the most common ones and document which ones are not supported (could even emit in the console that $state(new WhateverBuiltIn())
isn't reactive).
Finally regarding Date ... a reactive date certainly would be nice, but I've personally never felt the need for it. Compared to Collections (Set,Map) where it's quite handy to reach for their builtin methods (especially .delete) to handle component state, it doesn't seem to me that using a reactive Date to handle component local state is very useful? The docs can also cover this and say to use re-assignment for this scenario. date = new Date(date.setFullYear(2025))