-
-
Notifications
You must be signed in to change notification settings - Fork 4.5k
feat: add $lazy rune #11210
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
feat: add $lazy rune #11210
Conversation
|
Hey Dom, appreciate you spending time trying to solve this! Is this core idea for this to avoid having the // component.svelte
let count = $state(0);
let double = $derived(count * 2);
const countBox = $box(count, (v) => count = v)
const doubleBox = $box(double)
// ^? { value: number } to the consumer (getter/optional setter for reactivity)
doSomethingWithCount(countBox)
doSomethingWithDouble(doubleBox) // doSomething.svelte.ts
function doSomethingWithCount(count) {
$effect(() => {
alert(count.value)
})
}
function doSomethingWithDouble(double) {
$effect(() => {
if (double.value > 100) {
alert('slow down cowboy')
}
})
} With // component.svelte
let count = $state(0);
let double = $derived(count * 2);
let object = {
count: $lazy(count, value => count = value),
double: $lazy(double),
};
// do we have to pass in the whole obj to retain reactivity/mutability inside the fn?
doSomethingWithCount(object)
doSomethingWithDouble(object) So we end up with something like this if we don't want to pass the entire obj: // component.svelte
let count = $state(0);
let double = $derived(count * 2);
let countBox = {
value: $lazy(count, (v) => count = v)
}
let doubleBox = {
value: $lazy(double)
}
doSomethingWithCount(countBox)
doSomethingWithDouble(doubleBox) You could argue in the example above we could do: doSomethingWithCount({ value: $lazy(count, (v) => count = v )})
doSomethingWithDouble({ value: $lazy(double)}) but this same process would have to be applied each place the |
@huntabyte how's that limiting compared to |
Apologies, my example wasn't clear. |
Love that this is being pushed forward! What I'm worried about is the consuming part. createToggle()
// or
createToggle({pressed: $lazy(pressed, v => pressed = v)})
// both should be allowed
function createToggle(props) {
let pressed = $state()
// how to sync with props?
} A |
Optional as well (example updated). Literally the exact same as |
Is there a particular reason for this to work with arrays, or is it just because we can? That feels like a really weird use to me. It seems like extra code in the compiler to support code we probably don't want to encourage. The object one seems nice. |
I much prefer using tuples for many patterns over objects. It's nicer when passing around values that are read-only by design (using destructuring) as you can define the variable name without needing to do |
I agree with Thomas and Hunter here. Having a |
Derived can't be passed around without manually wrapping it in an object. |
Alright, I think this actually is more flexible than I thought. function box<T>(get: () => T, set: (v: T) => void) {
return {
value: $lazy(get, set)
}
}
function lazyArgs<O extends Record<string, unknown>, D extends Partial<O>>(object: O, defaults: D = {}): O {
return Object.keys({ ...object, defaults }).reduce((acc, key) => {
if (key in object) {
return { ...acc, [key]: $lazy(() => object[key], (v) => object[key] = v) }
} else {
const state = $state(defaults[key])
return { ...acc, [key]: $lazy(() => state, (v) => state = v) }
}
}, {} as O)
}
export function createToggle(_props: ToggleProps = {}) {
const props = lazyArgs(_props, {
pressed: false,
disabled: false,
});
// ...
}
const toggle = createToggle()
toggle.pressed // false
// or
let pressed = $state(true);
const toggleLazy = createToggle({pressed: $lazy(pressed, v => pressed = v)})
toggleLazy.pressed // true |
I'm more so focused on the expectation for the countless library APIs that will emerge, and how consumers will take that magic object and use it with them, for example: import { superCounter } from 'super-counter'
import { countHog } from 'count-hog'
const myMagicObj = {
count: $lazy(() => count))
}
$effect(() => {
if (myMagicObj.count > 100) {
alert('Woah, slow down!')
}
})
// magic counter expects a boxed { value: number } with getters and setters
superCounter(myMagicObj.count?)
// no wait
superCounter(myMagicObj)
// shit... still not working
superCounter({ value: $lazy(() => count)})
// ahh there where go!
// countHog expects a boxed { counter: number } with getters and setters
countHog({ counter: $lazy(() => count)}) Without prescribing some sort of "recommended/expected" (not forced) way for libraries to accept these "boxed" values, there could be countless ways a project may want to receive those values which adds mental burden onto the users, especially if they are using multiple libraries with vastly different ways of accepting such. |
@huntabyte I mean from my perspective as a user, that’s fine? Documentation exists for a reason and I don’t really expect everybody to have the same exact patterns? I would imagine though that the best patterns will eventually become a pseudo standard themselves. I honestly think lazy provides more flexibility here especially since this will also be used by regular devs who aren’t focused on building libraries in there use cases it seems much better dx and svelte like vibe being able to just interact with the values instead of needing a |
Exactly, most of us are not building libs. For me |
You're right that for non-lib-authors it's plenty, but that doesn't change the fact that in most apps the majority of the code comes from libraries. Given that Svelte 5 aims to close the gap between component and module reactivity a lot more can be done inside modules and thus a lot more can be done in libraries. So the fact Svelte doesn't have any predefined way of handling cross module reactivity is a huge design flaw. For example: Solid const [count, setCount] = createSignal(0); // count is a function thus in a closure, reactivity is kept Vue const count = ref(0); // Count is wrapped in a `.value` object and thus in a closure, reactivity is kept Svelte 5 const count = $state(0); // Count is a primitive, not in a closure, thus reactivity is not kept Why does Svelte 5 need to be so different? Needing to write |
Understood 100%. But keep in mind most apps rely heavily on libraries (whether internal or external). Some of the limitations/hacks required to accomplish things that are simple in other frameworks in previous versions are probably why most libs get abandoned after 6 months or are unable to reach their full potential. I want the experience to be as good as it can be for whatever you're using Svelte to do, which is why I'm perfectly fine with I honestly think the |
The reason we have boxing today, is because there's no way to pass around primitives by reference in JavaScript, unlike other languages. However, that can change, and I've been exploring some ideas around ideas of having something like that in JavaScript. Here's an old one for example https://github.com/rbuckton/proposal-refs. This is something I'm interested in now that I'm a TC39 delegate, so I'd like to see how this one plays out. |
Copy! Definitely misunderstanding. I took this as an alternative to
This would be awesome. Is this why you all haven't prescribed a "box" yet, in the hopes that it won't be necessary at all? |
100%. If we had refs in JavaScript, and maybe variable decorates, then everyone would get Svelte 5 DX with the addition of passing things through barriers – as part of the language. That's my mission and dream anyway. |
How do you tell TS that in let count = $state(0);
let double = $derived(count * 2);
let object = {
count: $lazy(count, value => count = value),
double: $lazy(double),
};
And won't it be annoying to most of the time pass to $lazy basically the same setter? What about indicating that you want to use a default simple setter? $lazy(value) // simple read-write
$lazy(value, (v) => value = v) // read and custom write
$lazy(value, null) // read-only |
Objects can have readonly properties in TS, I assume it would be auto-typed as such.
Same as with an object. With an object it'd be
I don't disagree necessarily with that, but think there may be a reason for it |
but what is
You can define a prop as readonly, but you cannot mark a value as readonly. And, AFAIK, you cannot preprocess |
const count = $state(0)
const arr = [$lazy(count, v => count = v)]
console.log(arr[0]) // 0
arr[0] = 1
console.log(arr[0]) // 1
What do you mean by value? In this case, |
I'm opposed to this. I don't think the modest increase in convenience pays for the reduction in clarity, and as mentioned, this doesn't work well with TypeScript: ![]() Moreover, I think the cases where you're manually writing getters are relatively few and far between — most of the time you're dealing with It's still too early to know what bottlenecks people will run into. Until we do, additions like this are premature.
Me too. The only places you use tuples like this are places where you're immediately destructuring them, which seems like it defeats the object. |
I think you're underestimating the number of times we need to do this. For example, props cannot be passed around easily and for libraries that need to receive state, we need getters. |
Following all the design decisions in Svelte 5 caused me to do my own thinking around what my ideal syntax would be if JS wasn't a barrier, and the syntax I landed on is basically the exact same as the |
And for the record, I think it would make sense if this rune generated getters and setters by default. It makes sense that if you want to have custom get/set logic, you have to write out the getter and setter, and if you want it to be readonly, then you just write the getter. That's normal JS and people are used to it. It's just in the case (which is the most common) that you want getting/setting the property to get/set the underlying state that the current syntax starts to feel really boilerplate-y. EDIT: should mention that would solve the TS problem above. |
Thank you for all the feedback on this PR. It was always an experiment to see how the syntax felt and we've decided it's not worth proceeding with. There are better ideas out there, and we'll keep looking until we find something that is ultimately right – this just wasn't that. |
It's the blessed way, so everyone will be doing it. I've been actively using Svelte 5, and I deliberately avoid using the functional approach to cross-boundary state because I dislike the get/set pattern so much. I went along with it at first because, oh, who knows why. But having been in the trenches long enough now, I rarely use the functional get/set approach. It ends up adding a lot of ugly boilerplate. Even the wrapped object proxy approach doesn't always solve the get/set ergonomics and can get complicated easily. I've gravitated toward using classes pretty much all the time now. Much cleaner, svelter. |
This PR introduces a new rune called
$lazy
. It's designed to be a shorthand replacement for writing out getters/setters on object expressions (although, this rune is also supported on arrays too, which is much harder to write with getters/setters).The rune has double fun meaning (because we're Svelte) – people using this are probably a bit lazy when they could have maybe also written getters/setters instead. :P
The
$lazy
rune can only be used in object/array expressions. If there it is only passed a single argument than the object property is read-only. If you pass the second argument, it must be a function setter. As shown:Or arrays:
You consume the lazy properties just like any other properties, there's no
.value
or some boxing/unboxing magic going on.