Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

$state mutation callback #15032

Open
rChaoz opened this issue Jan 16, 2025 · 2 comments · May be fixed by #15069
Open

$state mutation callback #15032

rChaoz opened this issue Jan 16, 2025 · 2 comments · May be fixed by #15069

Comments

@rChaoz
Copy link
Contributor

rChaoz commented Jan 16, 2025

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:

  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:

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

@Thiagolino8
Copy link

The problem with your example is that your state is not the source of truth, if your state cannot be trusted to have the most recent value then it has no reason to exist.

import { on } from 'svelte/events'
import { createSubscriber } from 'svelte/reactivity'

export function localStorageState<T>(key: string, initialValue: T) {
	localStorage.setItem(key, JSON.stringify(initialValue))
	const subscribe = createSubscriber((update) => {
		const remove = on(window, 'storage', (event: StorageEvent) => {
			if (event.key === key) {
				update()
			}
		})

		return remove
	})
	return {
		get current() {
			subscribe()
			return JSON.parse(localStorage.getItem(key)!)
		},
		set current(value: T) {
			localStorage.setItem(key, JSON.stringify(value))
		},
	}
}

@Rich-Harris
Copy link
Member

I agree that we need something like this. I don't think it makes sense for the callback to receive newValue, because it suggests you can compare it with the old value, which isn't the case when mutating.

The problem with your example is that your state is not the source of truth

Not sure I understand this?

@paoloricciuti paoloricciuti linked a pull request Jan 20, 2025 that will close this issue
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants