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

Open
rChaoz opened this issue Jan 11, 2025 · 22 comments
Open

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

rChaoz opened this issue Jan 11, 2025 · 22 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.

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

5 participants