Description
Describe the problem
Currently, to pass reactive values to functions or class instances, you have to either:
-
Wrap the function or class instance in
$derived
(which means it will be re-created whenever the values change), -
Do it imperatively by exposing setters and syncing changes with
$effect
. -
Pass the values as function getters or box objects.
const { step = 1 } = $props();
// Derived:
const counter = $derived(createCounter(step));
// Setter:
const counter = createCounter();
$effect(() => {
counter.step = step;
});
// Function getter:
const counter = createCounter(() => step);
// Box object:
const counter = createCounter({
get value() {
return step;
}
});
Wrapping the function in $derived
allows you to keep the function’s implementation unchanged, use the argument values directly without any unwrapping, and destruct the functions return value while keeping the reactivity. However, the function will be re-created every time the values change, which may not be so performant.
Exposing setters on the other hand, is decent in a way that it’s very explicit what’s going on but it will require boilerplate code and must rely on $effect
to sync state in a way that doesn’t feel quite right.
Passing function getters or box objects might not seem like a big deal, especially in simple cases, and TypeScript make this easier. But, in my experience working on and maintaining a large Vue codebase, where much logic resides in composables, this eventually starts to clutter your code to that extent that becomes harder to reason about as it forces you to constantly wrap and unwrap values back and forth and think about how data is passed around. It simply feels very unnatural. This in contrast with React, where you can pass values across function boundaries and it will just work:
function useCounter(step = 1) {
const [count, setCount] = useState(0);
return {
count,
increment() {
setCount((prev) => prev + step);
},
decrement() {
setCount((prev) => prev - step);
}
};
}
function MyCounter({step = 1}) {
const { count, increment, decrement } = useCounter(step);
return (
// ...
)
}
Describe the proposed solution
For Svelte, I believe this could be made more effectively by introducing a rune for defining “hooks” or "composables," which would handle keeping values reactive across function boundaries behind the scenes. Similar to $derived
, it could also support destructuring of the return value.
Consider this:
const createCounter = $hook((step = 1) => {
let count = $state(0);
return {
get count() {
return count;
},
increment() {
count += step;
},
decrement() {
count -= step;
}
};
});
const { step = 1 } = $props();
const { count, increment, decrement } = $use(createCounter, step);
This approach could potentially be applied to element actions as well, making the update()
hook redundant, as you could instead use $effect
:
<script>
let text = $state('Hello');
const sayHello = $action((element, {text}) => {
$effect(() => {
element.innerText = text;
});
// Optional destructor
return () => {};
});
</script>
<button type="button" onclick={() => text = 'Bonjour'}>Say Bonjour</button>
<div use:sayHello={{text}}></div>
Now, I have no idea if it's difficult to implement something like this, or if there's something I haven't considered that makes it less ideal. I also don't have a solution for how to combine this with class instances. However, having the constraint applied once for the function and once when invoking it — rather than constantly dealing with wrapping and unwrapping argument values — will lead to cleaner code and a more straightforward developer experience, in my opinion.
Importance
would make my life easier