Description
TL;DR
Reacting to deep state changes in external (.svelte.js
) modules is complicated - the function cannot know if it's running from a component context (can use $effect
) or not, for example, to create global state (has to create its own $effect.root
, and worry about the cleanup). Additionally, effects are not synchronous, making certain patterns impossible. The alternative is custom set/update functions, which can become complicated with deep/nested properties, and cannot take advantage of all of the goodies $state
proxies offer, like reactive array pushes.
The proposal is to allow being notified of changes to a single owned $state
using a callback:
const myState = $state({ ... }, {
// function definition is of course up to discussion
onchange(newValue) {
// do stuff with newValue synchronously
}
})
Introduction
To explain this problem, I will use the example of implementing a local storage-synced state/store. You can also see the full discussion that lead to this in #14978, in fact, this whole proposal is "borrowed" from Rich's idea.
Replacing stores with state generally works great, except when working with functions that are meant to work both globally, and scoped to components. For the local storage-synced store, let's consider a few simple goals:
- Synchronous -
$store = "new value"; expect(localStorage.getItem(key)).toEqual("new value")
- Easy to use/no API for nested properties -
$store.nested.prop = ...
is detected - React to cross-tab changes, but only when subscribed to
The goals (1) and (2) are built-in by stores, which is why many Svelte 4 users have come to take them for granted. More specifically, doing $store.a.b = 123
calls store.update((value) => { value.a.b = 123; return value })
. This removes the need to manually call the update function, although at a cost: this is very unintuitive for new users, and doesn't work with functions like Array.push
. Goal (3) can be achieved with the StartStopNotifier
interface, and a implementation might look something like this:
function localStorageStore(key, initialValue) {
const store = writable(initialValue, (set) => {
const callback = (event) => set(...)
window.addEventListener("storage", callback)
return () => window.removeEventListener("storage", callback)
});
return {
subscribe: store.subscribe,
set(value) {
localStorage
store.setItem(key, value)
},
// update() omitted
}
}
Not very complicated once you understand the store contract, and works both globally and in components, since there the code is plain JS.
The problem
Now, let's try to achieve the same with the new state/runes. Once again, we want the solution to work both globally (i.e. we can declare a global top-level localStorageState
instance), but also use it in components, and we want to achieve our three existing goals. To achieve goal (1), we simply use a set function, or a setter for a current
property. To achieve goal (2), the compiler doesn't "help" us anymore, instead we could use an effect. However, as effects are not synchronous, this conflicts with goal (1). Goal (3) is a little trickier to implement, as we need to use both createSubscriber
and $effect.tracking
, but achievable nonetheless. Let's consider a simple implementation that does not implement goal (2):
function localStorageState(key, initialValue) {
let state = $state(initialValue)
const subscribe = createSubscriber(() => {
const callback = (event) => (state = ...)
window.addEventListener("storage", callback)
return () => window.removeEventListener("storage", callback)
});
return {
get current() {
if ($effect.tracking()) {
subscribe()
return state
} else {
// if there are no subscribers, state might be out of sync with localStorage
return localStorage.getItem(key)
}
},
set current(value) {
localStorage.setItem(key, value)
state = value
}
}
}
This works, but it is rather cumbersome to use with nested props, as state.current.nested = 123
will not trigger our custom getter, while it will trigger reactive updates due to the $state
proxy. Instead, we can use $state.raw
to discourage this, and then do state.current = { ...state.current, nested: 123 }
to perform an update. This can become more complicated for more nested properties, and neat new stuff that runes allow, like arrays and custom classes, possibly even requiring an external package like deepmerge
to handle the update. We can give up goal (1) to try and fix this:
$effect(() => {
localStorage.setItem(key, JSON.stringify(value))
})
...however this will fail with effect_orphan
when used globally. We can fix this by wrapping the effect in an $effect.tracking
, but then we'd have to worry about the clean-up.
All in all, implementing certain complex patterns with state, which include reacting to deep state changes, requires convoluted $effect.root
s or unsynchronous updates.
The solution
$state
knows best - it creates a deep proxy that does a lot of stuff - proxifying new values when they are added, remembering who owns what... and by doing all that it also knows when the state itself is changed, even by nested properties. Why wouldn't it share that information with us?
$state(value, { onchange() { ... } }
This would solve every single problem in the example above: synchronously setting the local storage, not having to worry about $effect.root
and its cleanup, and so it would work identically globally or in components:
function localStorageState(key, initialValue) {
const state = $state(initialValue, {
onchange(newValue) {
localStorage.setItem(key, newValue)
}
})
const subscribe = createSubscriber(() => {
const callback = (event) => (state = ...)
window.addEventListener("storage", callback)
return () => window.removeEventListener("storage", callback)
});
return {
get current() {
if ($effect.tracking()) {
subscribe()
return state
} else {
return localStorage.getItem(key)
}
},
set current(value) {
state = value
}
}
}
Importance
would make my life easier