Repository: svelte
Issue: `$state` mutation callback
Labels: feature request,runes

## 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:

```js
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](https://github.com/sveltejs/svelte/issues/14978#issuecomment-2591177993).

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:
1) **Synchronous** - `$store = "new value"; expect(localStorage.getItem(key)).toEqual("new value")`
2) **Easy to use/no API for nested properties** - `$store.nested.prop = ...` is detected
3) **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:

```js
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):

```js
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:

```js
$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?

```js
$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:

```js
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