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

[Skill Issue] State and runes are too difficult #14978

Closed
rChaoz opened this issue Jan 11, 2025 · 28 comments
Closed

[Skill Issue] State and runes are too difficult #14978

rChaoz opened this issue Jan 11, 2025 · 28 comments

Comments

@rChaoz
Copy link
Contributor

rChaoz commented Jan 11, 2025

Describe the problem

Deeply reactive state is great, but the simplicity of Svelte 4 is something that I still can't find ways to bring back. Consider this:

const [debounced, instant]= debounced({ prop: 123 })

// this triggers the debounced store to change after a delay
$instant.prop = 456

You don't really need to know the implementation of debounce() to understand how it might work, subscribing to the first store and setting the second's value after a timeout. For complicity, here's the implementation:

export function debounced<T>(initial: T, delay: number = 1000): [Writable<T>, Writable<T>] {
    let timeout: ReturnType<typeof setTimeout>
    const debounced = writable(initial)
    const instant = writable(initial)

    // update() implementations omitted
    return [
        {
            subscribe: debounced.subscribe,
            set(value) {
                clearTimeout(timeout)
                debounced.set(value)
                instant.set(value)
            },
        },
        {
            subscribe: instant.subscribe,
            set(value) {
                clearTimeout(timeout)
                instant.set(value)
                timeout = setTimeout(() => debounced.set(value), delay)
            },
        },
    ]
}

Now, I've been trying to convert this to Svelte 5, without too much success. My requirements are:

  1. clean implementation (no effects),
  2. must work with nested properties.

After all, this is why I loved Svelte so much over React - no need to worry about state or creating new objects/arrays everytime you assign. And yet, ever since I started working with Svelte 5, this is all I am allowed to think about. I remember it took me around 15 minutes to come up with the above implementation and I thought that stores are nice. How about state, tho? I managed to quickly make a function to respect my first condition:

export function debounced<T>(initial: T, delay: number = 1000): { debounced: T; instant: T } {
    let debounced = $state.raw(initial)
    let instant = $state.raw(initial)
    let timeout: ReturnType<typeof setTimeout>

    return {
        get debounced() {
            return debounced
        },
        set debounced(newValue) {
            clearTimeout(timeout)
            debounced = newValue
            instant = newValue
        },
        get instant() {
            return instant
        },
        set instant(newValue) {
            instant = newValue
            clearTimeout(timeout)
            timeout = setTimeout(() => (debounced = newValue), delay)
        },
    }
}

This looks great, but what about the second condition, that this should work with nested properties like it used to? I could make it work with some effects like:

$effect.root(() => {
    $effect(() => {
        // listen to changes anywhere in the state
        void $state.snapshot(instant)
        // propagate the change
        setTimeout(() => (debounced = newValue), delay)
    })
})

This might work, but I have so many questions:

  • Does this cause a memory leak, or can the effect root be GC'd?
  • If the answer to the above question is true, how can this be achieved instead?
  • If state is supposed to simplify stores, why did I have to learn 3 complex and weird runes and their quirks just to achieve 10% of what stores can without any knowledge whatsoever?
  • Why?

Maybe this is just a skill issue for me, but this is like the 3rd time I run into such a roadblock while trying to migrate my code. Most of the type I'm just trying to decide between an object with a current property, a function or God knows what when I could just use a store. I understand stores aren't deprecated per-se, but with $app/stores being deprecated in Kit it's tough to say what the intended direction is.

Describe the proposed solution

Thr dollar sign abstractions in Svelte 4 were the closest thing we ever got to world peach, and with every rune we stray further from God. I whole-heartedly understand why runes, but it is frustrating to 10x complicate my code when it's supposed to be the opposite, They're amazing on paper and in demos, not so much in practice.

What do I want? Not sure, but some better documentation on how to use them, when to use current versus functions, a bunch of examples for more complex cases. Maybe I just wanted to vent a little.

Importance

nice to have

@rChaoz rChaoz changed the title State and runes are too complex [Skill Issue] State and runes are too difficult Jan 11, 2025
@rChaoz rChaoz changed the title [Skill Issue] State and runes are too difficult [Skill Issue] State and runes are too difficult, also new $watch rune Jan 11, 2025
@rChaoz rChaoz changed the title [Skill Issue] State and runes are too difficult, also new $watch rune [Skill Issue] State and runes are too difficult Jan 11, 2025
@webJose
Copy link
Contributor

webJose commented Jan 11, 2025

You can't dismiss runes because of one "good" example of a bad consequence. Sure, I see your point: With "$", Svelte would call set() for you, even for changes in sub-properties, where the timeout logic resides.

The good news is that you may stay as you are and continue using stores, as they don't have a foreseeable deprecation date. Moreover, your use case can even solidify the need to continue keeping stores around.

I believe that runes do simplify many more scenarios than it complicates, with the added benefits of performance gains and the ability to refactor code. Regardless of what the core team thinks or may have thought, this is a good example/reminder that signals may not be the one solution for everything.

@david-plugge
Copy link

david-plugge commented Jan 11, 2025

Make use of $state.snapshot:

export class Debounced<T> {
	#current = $state() as $state.Snapshot<T>;

	constructor(get: () => T, delayMs: number) {
		this.#current = $state.snapshot(get());

		$effect(() => {
			const value = $state.snapshot(get());
			const timeout = setTimeout(() => {
				this.#current = value;
			}, delayMs);
			return () => clearTimeout(timeout);
		});
	}

	get current() {
		return this.#current;
	}
}
<script lang="ts">
	import { Debounced } from '$lib/utils/state/debounced.svelte';

	let value = $state({ email: '', age: 18 });
	const debounced = new Debounced(() => value, 300);
</script>

<div class="flex flex-col gap-2 mx-auto">
	<input class="border" type="text" bind:value={value.email} />
	<input class="border" type="number" bind:value={value.age} />

	<pre>{JSON.stringify(debounced.current, null, 2)}</pre>
</div>

@david-plugge
Copy link

david-plugge commented Jan 11, 2025

Or alternatively:

export class Debounced<T> {
	value = $state() as T;
	#current = $state() as $state.Snapshot<T>;

	constructor(initial: T, delayMs: number) {
		this.value = initial;
		this.#current = $state.snapshot(initial);

		$effect(() => {
			// create a snapshot to make the debounced state deeply reactive
			const value = $state.snapshot(this.value);
			const timeout = setTimeout(() => {
				this.#current = value;
			}, delayMs);
			return () => clearTimeout(timeout);
		});
	}

	get current() {
		return this.#current;
	}
}
<script lang="ts">
	import { Debounced } from '$lib/utils/state/debounced.svelte';

	const debounced = new Debounced(
		{
			email: '',
			age: 18
		},
		300
	);
</script>

<div class="flex flex-col gap-2 mx-auto">
	<input class="border" type="text" bind:value={debounced.value.email} />
	<input class="border" type="number" bind:value={debounced.value.age} />

	<pre>{JSON.stringify(debounced.current, null, 2)}</pre>
</div>

@david-plugge
Copy link

david-plugge commented Jan 11, 2025

Or use the runed package for various utilities

@rChaoz
Copy link
Contributor Author

rChaoz commented Jan 11, 2025

Hi @david-plugge, thanks for the solution! Unfortunately, I cannot directly use $effect as my debounced is not guaranteed to be called from a component init, and there's still no way to tell it you are in one or not. The runed solution is good but doesn't set the source when using setImmediately().

@david-plugge
Copy link

david-plugge commented Jan 11, 2025

In that case you may use createSubscriber:

import { createSubscriber } from 'svelte/reactivity';

export class Debounced<T> {
	#current: $state.Snapshot<T>;
	#subscribe: () => void;

	constructor(get: () => T, delayMs: number) {
		this.#current = $state.snapshot(get());
		this.#subscribe = createSubscriber((update) => {
			return $effect.root(() => {
				$effect(() => {
					const value = $state.snapshot(get());
					const timeout = setTimeout(() => {
						this.#current = value;
						update();
					}, delayMs);
					return () => clearTimeout(timeout);
				});
			});
		});
	}

	get current() {
		this.#subscribe();
		return this.#current;
	}
}

@david-plugge
Copy link

A complete example could look like this:

import { createSubscriber } from 'svelte/reactivity';

export class Debounced<T> {
	#get: () => T;
	#current: $state.Snapshot<T>;
	#subscribe: () => void;
	#updateFn: (() => void) | null = null;

	constructor(get: () => T, delayMs: number) {
		this.#get = get;
		this.#current = $state.snapshot(get());
		this.#subscribe = createSubscriber((update) => {
			return $effect.root(() => {
				$effect(() => {
					const value = $state.snapshot(get());
					this.#updateFn = () => {
						this.#current = value;
						update();
						cleanupFn();
					};
					const cleanupFn = () => {
						this.#updateFn = null;
						clearTimeout(timeout);
					};
					const timeout = setTimeout(this.#updateFn, delayMs);
					return cleanupFn;
				});
			});
		});
	}

	get current() {
		if ($effect.tracking()) {
			this.#subscribe();
			return this.#current;
		}
		return this.#get();
	}

	updateImmediately(): Promise<void> {
		return new Promise((resolve) =>
			setTimeout(() => {
				this.#updateFn?.();
				resolve();
			}, 0)
		);
	}
}

@webJose
Copy link
Contributor

webJose commented Jan 11, 2025

I suppose that @david-plugge 's solution is proving the original point: Signals and fine-grained reactivity work against the desired coarse reactivity. If I were @rChaoz, I would continue using the store implementation.

@paoloricciuti
Copy link
Member

Why create an effect root and an effect inside subscriber? The whole point of create subscriber is to initialize listeners and clear them when they are not needed anymore.

Also in general the coarse grained reactivity you want can be achieved with a custom recursive proxy

@rChaoz
Copy link
Contributor Author

rChaoz commented Jan 11, 2025

I don't want course reactivity. But @david-plugge's great solution does a few important points:

  • to achieve what could be achieved with stores by understanding the very simple subscribe() and set() API you need to have a deep understanding on state, proxies, a much larger API surface with $effect, $effect.root, $state and $state.snapshot, getters for reactive properties, functions for reactive data, createSubscriber(). If I remember correctly, reducing the API surface was one of the main goals of Svelte 5
  • it's impossible to achieve what stores can with runes. You kind-of can using effects, but this will break the state/store contract, i.e. value = 2; expect(doubled).toBe(4) will fail, unless you use a flushSync() in-between, this is why the solution above does not implement the two-way connection I need (setImmediately() should also modify the original state)
  • due to effects, you are forced to write context-aware code, which either cannot be shared between different places, unlike store-based code, or you fall into the $effect.root / $effect.tracking rabbit hole

All in all, it feels like something is missing or wrong, but I can't say exactly what it is.

@webJose
Copy link
Contributor

webJose commented Jan 11, 2025

I don't know if you're missing the point: Svelte v5 uses fine-grained reactivity, which simplifies well over 90% of all code bases; your case is particular, special: It benefits from the old coarse-grained reactivity which happens to be the strong point in Svelte v4.

Just stick to stores. They aren't deprecated.

@rChaoz
Copy link
Contributor Author

rChaoz commented Jan 11, 2025

In the end, this is the solution I used (repl):

type DebouncedState<T> = {
    /**
     * The debounced value, available after a delay.
     * Setting this property is also reflected immediately in `instant`.
     */
    debounced: T
    /**
     * The instant value, which propagates to `debounced` after a delay.
     */
    instant: T
}

/**
 * Returns an object with two **raw** reactive properties - **`debounced`** and **`instant`**, both starting with the given initial value.
 * Changes performed to `instant` will be forwarded to `debounced` after the specified delay.
 * If multiple changes happen in quick succession (less time between them than the specified delay), only the last change is forwarded.
 * Changes performed to `debounced` are instantly reflected in properties.
 *
 * The properties are not deeply reactive, meaning that changes to nested properties will not be detected, and the properties cannot be destructured.
 *
 * @param initial The initial value of the state.
 * @param delay Time until a change until a change to `instant` is propagated to `debounced` (milliseconds)
 * @returns A state with `debounced` and `instant` raw reactive properties
 */
export function debounced<T>(initial: T, delay: number = 1000): DebouncedState<T> {
    let instant = $state.raw(initial)
    let debounced = $state.raw(initial)
    let timeout: ReturnType<typeof setTimeout> | undefined

    return {
        get instant() {
            return instant
        },
        get debounced() {
            return debounced
        },
        set instant(value) {
            clearTimeout(timeout)
            instant = value
            timeout = setTimeout(() => (debounced = value), delay)
        },
        set debounced(value) {
            clearTimeout(timeout)
            debounced = value
            instant = value
        },
    }
}

It's basically the same solution with stores, except that you cannot assign to properties, which I guess was a strong point, but also a bit of a gimmick.

@paoloricciuti
Copy link
Member

You don't need effects for this, I'll see if I can write you an example

@rChaoz
Copy link
Contributor Author

rChaoz commented Jan 11, 2025

See my solution above, it's the closest thing to the original stores solution. I was wondering if it's possible to make a version that's deeply reactive, but that seems impossible.

@paoloricciuti
Copy link
Member

See my solution above, it's the closest thing to the original stores solution. I was wondering if it's possible to make a version that's deeply reactive, but that seems impossible.

It is and it's quite easy with proxies... I'll show an example when I'm free

@david-plugge
Copy link

Why create an effect root and an effect inside subscriber? The whole point of create subscriber is to initialize listeners and clear them when they are not needed anymore.

Since i'm "listening" for the state i do need the effect dont i? But at that point i also know that im in an effect due to $effect.tracking()...

My goal was to get as close as possible to the behavior with stores.

@rChaoz
Copy link
Contributor Author

rChaoz commented Jan 11, 2025

I believe that I've figured out exactly what it is that I want: a way to tap into Svelte's deep proxies. Something like $watch(deepProxy, { ...handlers... }), with handlers that receive the full path. That would actually solve all of my problems - being able to tell when a reactive state changes without effects, through properties, array pushes, SvelteMaps...

@paoloricciuti
Copy link
Member

paoloricciuti commented Jan 11, 2025

I believe that I've figured out exactly what it is that I want: a way to tap into Svelte's deep proxies. Something like $watch(deepProxy, { ...handlers... }), with handlers that receive the full path. That would actually solve all of my problems - being able to tell when a reactive state changes without effects, through properties, array pushes, SvelteMaps...

Which is exactly what you should build with a simple reactive proxy...no need of new runes

@paoloricciuti
Copy link
Member

@rChaoz
Copy link
Contributor Author

rChaoz commented Jan 11, 2025

I see... that's a nice solution, but can get more complex the more you want from it.

If Svelte offerred a way to tap into the deep proxy of a state, this would be much easier, more efficient, and work with everything that state already works with (array push/other functions, custom classes with state, SvelteMap...). Not quite sure how that might look, probably not proxy-style get/set/other traps, but a way to detect changes nonetheless.

@dummdidumm
Copy link
Member

dummdidumm commented Jan 11, 2025

The core issue here is that $foo.bar = x is syntax sugar for foo.update($foo => { $foo.bar = x; return $foo; }). So if you would create a runes version of your debounce which exposed set/update methods you'd get the same behavior and it was easy to implement, just not as convenient to use compared the the syntax sugar.
So the question is how far you want to go with implementation complexity in pursuit of the most ergonomic usage

@rChaoz
Copy link
Contributor Author

rChaoz commented Jan 11, 2025

That's a good point, and probably what I'll end up using. I would've loved if there was a way to hijack the new deep reactivity and be able to obtain the old $store.a.b = ..., but also with Array.push or similar.

@Rich-Harris
Copy link
Member

There is a skill issue here, but it was on the part of whichever nincompoops designed stores in the first place. While it might feel like there's an appealing simplicity to the store version (at least, once you've wrapped your head around the looks-simple-at-first-but-is-actually-quite-convoluted store contract, which I suspect you omitted from your 15 minute time estimate), the reality is that it's buggy as all hell.

You'd think this if block would only be visible after the debouncing is finished, right?

<p>debounced: {JSON.stringify($debounced)}</p>
<p>instant: {JSON.stringify($instant)}</p>

<!-- this should not be visible when debouncing is active! -->
{#if $debounced.prop === $instant.prop}
  <p>{$debounced.prop} === {$instant.prop}</p>
{/if}

Wrong. In Svelte 4, the code that gets generated looks like this...

if (/*$debounced*/ ctx[1].prop === /*$instant*/ ctx[0].prop) {
  // ...
}

...and since $debounced and $instant are actually the same object, that returns true. But since Svelte 4 is very conservative about updating the DOM, when it comes to the text nodes in question, it does this...

if (dirty & /*$debounced*/ 2 && t0_value !== (t0_value = /*$debounced*/ ctx[1].prop + "")) set_data(t0, t0_value);
if (dirty & /*$instant*/ 1 && t2_value !== (t2_value = /*$instant*/ ctx[0].prop + "")) set_data(t2, t2_value);

...resulting in absurd outcomes like <p>123 === 124</p>.

In Svelte 5, we don't use a dirty bitmask, so there are no stale values. But this just means that $debounced.prop has one value in one part of the template and another value in another part of the template, and the if block is always shown. (Technically this is a breaking change that went unnoticed until now, but does a change from one form of brokenness to a different form of brokenness count?)

This is, of course, very very bad. It's the sort of bug that most people can happily ignore for years, but when it bites you it bites you hard. A framework that accepted those issues rather than working to eliminate them would be deeply unserious. Svelte 5 simply forces you to reckon with the footguns involved in dealing with mutable objects.

I do think there'd be some value in having a way to detect changes within a state proxy — I've wondered about this sort of thing, which might make people less inclined to build houses of cards around $effect.root:

let object = $state(load('my-key') ?? {...}, {
  onchange: () => {
    save('my-key', object);
  }
});

@webJose
Copy link
Contributor

webJose commented Jan 14, 2025

@Rich-Harris thanks for the feedback. Although I'm not the OP, I do have a solid need for stores to be around: Micro-frontends. Store reactivity works across MFE's, while signals do not. This happens because the MFE's use different copies of the Svelte runtime.

If I may be so bold: Please have this scenario in mind when you plan towards decommissioning stores. Ideally, it would be nice if the Svelte runtime could be externalized, so the resulting MFE-based application can share signal reactivity across projects.

For whatever is worth, I'm open to helping with anything I can towards an effort to make MFE's cross-reactive. Thanks!

@paoloricciuti
Copy link
Member

@Rich-Harris thanks for the feedback. Although I'm not the OP, I do have a solid need for stores to be around: Micro-frontends. Store reactivity works across MFE's, while signals do not. This happens because the MFE's use different copies of the Svelte runtime.

If I may be so bold: Please have this scenario in mind when you plan towards decommissioning stores. Ideally, it would be nice if the Svelte runtime could be externalized, so the resulting MFE-based application can share signal reactivity across projects.

For whatever is worth, I'm open to helping with anything I can towards an effort to make MFE's cross-reactive. Thanks!

Fwiw I did explore this a bit and a new release of em.sh + import maps and externalization should solve the issue (still didn't try tho)...the alternative could be to bundle and serve a svelte version yourself.

But I think this is generally a problem of microfrontends right?

@webJose
Copy link
Contributor

webJose commented Jan 14, 2025

I guess this problem is common in MFE's, yes, because signals usually depend on a central location that orchestrates the change propagation. I suppose that Angular and SolidJS, for instance, would exhibit the same limitation. However, I care not (currently) about any of those because I don't see myself abandoning beautiful Svelte. 😄

I remember that you (Paolo) experimented a bit, and you left me a message to test. I just have had too much of other things to do, and unfortunately did not test.

Generally speaking, however, and speaking in favor of stores: It is such a generic contract that is so simple to satisfy that I personally think it would be a shame if they disappeared. I can make anything reactive, even cross-MFE, by providing a subscribe() method. That's powerful.

@rChaoz
Copy link
Contributor Author

rChaoz commented Jan 15, 2025

There is a skill issue here, but it was on the part of whichever nincompoops designed stores in the first place. While it might feel like there's an appealing simplicity to the store version (at least, once you've wrapped your head around the looks-simple-at-first-but-is-actually-quite-convoluted store contract, which I suspect you omitted from your 15 minute time estimate), the reality is that it's buggy as all hell.

I guess, after all, the real problem was trying to think in stores, but with state. I'm guessing more people might have done this, since stores were pretty much the standard (and usually only) way to share state in earlier versions, be it global state, contexts, or interacting with regular JS.

Now, I would really love to see the $state onchange thing, as mentioned I thing it would be great for all of these use-cases of state that's not necessarily tied to a component that needs to be watched, like syncing with local storage.

@paoloricciuti
Copy link
Member

Since you opened an issue with an actual feature request i'm gonna close this one @rChaoz is that ok? Feel free to reopen if you think there's a reason for it.

@paoloricciuti paoloricciuti closed this as not planned Won't fix, can't repro, duplicate, stale Jan 16, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants