svelte icon indicating copy to clipboard operation
svelte copied to clipboard

Reactive Map, Set and Date

Open Rich-Harris opened this issue 1 year ago • 28 comments

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 possibly WeakSet and WeakMap) — 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

Rich-Harris avatar Jan 22 '24 17:01 Rich-Harris

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?

harrisi avatar Jan 23 '24 23:01 harrisi

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)

dummdidumm avatar Jan 24 '24 10:01 dummdidumm

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?

harrisi avatar Jan 24 '24 21:01 harrisi

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.

katriellucas avatar Jan 24 '24 21:01 katriellucas

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

MotionlessTrain avatar Jan 24 '24 22:01 MotionlessTrain

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.

harrisi avatar Jan 24 '24 23:01 harrisi

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

Antonio-Bennett avatar Jan 25 '24 03:01 Antonio-Bennett

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.

dummdidumm avatar Jan 25 '24 08:01 dummdidumm

if you import svelte/reactivity, is runes mode enabled?

No, they're completely orthogonal

could Svelte not just rewrite let map = $state(new Map()) to import { 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());

Rich-Harris avatar Jan 25 '24 19:01 Rich-Harris

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.

MentalGear avatar Feb 04 '24 19:02 MentalGear

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

MotionlessTrain avatar Feb 04 '24 20:02 MotionlessTrain

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.

dummdidumm avatar Feb 05 '24 08:02 dummdidumm

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

Zachiah avatar Feb 05 '24 19:02 Zachiah

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:

  1. The dev defines the expected behaviour
import { get_map } from './somewhere.js';
let map = $state(get_map(), { addReactivity: ["Map"] }  );
  1. Now the compiler knows that the proxied built-in "Map" is needed and packs it.
  2. When the $state is modified during runtime, the proxy function checks the object's prototype and proxies the Map, just like it does for Arrays or Objects 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.

MentalGear avatar Feb 08 '24 01:02 MentalGear

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?

MotionlessTrain avatar Feb 08 '24 08:02 MotionlessTrain

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).

MentalGear avatar Feb 08 '24 21:02 MentalGear

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.

MotionlessTrain avatar Feb 08 '24 23:02 MotionlessTrain

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.

MentalGear avatar Feb 09 '24 17:02 MentalGear

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 }

MotionlessTrain avatar Feb 09 '24 20:02 MotionlessTrain

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()) 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.

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 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.

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.

harrisi avatar Feb 10 '24 07:02 harrisi

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.

MentalGear avatar Feb 12 '24 11:02 MentalGear

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 that reactivity 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 update size etc), or we create a class that extends Map/Set etc and does the same. Either way, we need special code for each class.

dummdidumm avatar Feb 12 '24 11:02 dummdidumm

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).

MentalGear avatar Feb 12 '24 20:02 MentalGear

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?

7nik avatar Feb 12 '24 20:02 7nik

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".

MentalGear avatar Feb 12 '24 23:02 MentalGear

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.

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).

harrisi avatar Feb 13 '24 04:02 harrisi

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.

7nik avatar Feb 13 '24 11:02 7nik

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.

harrisi avatar Feb 13 '24 16:02 harrisi

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')

sheecegardezi avatar Feb 20 '24 13:02 sheecegardezi

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))

Hebilicious avatar Feb 20 '24 20:02 Hebilicious