Skip to content

Svelte 5: Introduce $derived.with rune #9968

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

Closed
Not-Jayden opened this issue Dec 20, 2023 · 26 comments
Closed

Svelte 5: Introduce $derived.with rune #9968

Not-Jayden opened this issue Dec 20, 2023 · 26 comments
Labels

Comments

@Not-Jayden
Copy link
Contributor

Not-Jayden commented Dec 20, 2023

Describe the problem

This is a bit of a rehashing of #9250 prompted by seeing more of this discussion coming up in the Discord

TL:DR there's still animosity around people's preferences regarding the design choice for $derived accepting an expression instead of a callback function, and how it becomes slightly less ideal for the scenarios where you want to encapsulate more complex logic that wraps onto multiple lines.

Describe the proposed solution

Introduce a $derived.with rune, which accepts a callback for determining the derived value instead of an expression.

e.g.

const total = $derived.with(() => {
  const discountAmount = cartTotal * promoDiscount;
  const taxedAmount = (cartTotal - discountAmount) * taxRate;

  return cartTotal - discountAmount + taxedAmount;
});

Alternatives considered

  • Keep using IIFE's or defining the logic in functions outside of $derived, given it's really not that big of a deal
  • A separate $computed rune instead of not being nested under $derived
  • Alternative method names: call, compute, using, from

Importance

nice to have

@kyle-n
Copy link

kyle-n commented Dec 20, 2023

As the person who sparked the Discord discussion with my blog post, I'd like to second this proposal.

Having the option to use a callback to $derived.with() or $computed() means we avoid verbose alternatives like:

  • IIFEs (awkward syntax, easy to mistype)
  • Separate function that’s never reused anywhere (harder to follow reading, extra indirection, unnecessary hoop-jumping)

I appreciate Svelte's focus on writing less code and hope it will apply here too.

@aradalvand
Copy link

aradalvand commented Dec 25, 2023

Seconded, though I prefer the overload approach, adding an extra overload that accepts a function:

let foo = $derived(() => {
    // do calculations and return
});

While preserving the current signature as the other overload:

let bar = $derived(foo * 2);

But if overloads aren't feasible, $derived.with is fine as well, either way, this should be added IMO.

@Not-Jayden
Copy link
Contributor Author

Not-Jayden commented Dec 26, 2023

I worry overloading $derived to behave differently depending on whether it receives a function would probably cause too much confusion.

For example in this case:

function getTotal() {
  const discountAmount = cartTotal * promoDiscount;
  const taxedAmount = (cartTotal - discountAmount) * taxRate;

  return cartTotal - discountAmount + taxedAmount;
}

const total = $derived(getTotal);

The way $derived() works now it's easy to understand you're just going to get the reactive result of the expression passed into it. If it were to be overloaded, it's not immediately obvious to me whether total would be a function, or if it should get called and return the value reactively.

Also need to consider that it would be a breaking change to alter how $derived behaves directly, even though 5 isn't a GA release yet.

@kyle-n
Copy link

kyle-n commented Dec 26, 2023

I think overloading $derived to accept an expression or a callback would be ideal. Checking if something is a function is pretty negligible performance-wise. Allowing callbacks also provides the shortest, most developer-friendly syntax.

Svelte is all about writing less code. Let’s not have a separate function or an IIFE or $derived.with if we can skip it (though it’s an acceptable second option in my book).

@TGlide
Copy link
Member

TGlide commented Dec 26, 2023

Overloading is not the best idea. What if I want to return a function from derived? Now I need to return a function from within a function.

@aradalvand
Copy link

aradalvand commented Dec 27, 2023

Okay, the reasons against it being an overload sound compelling. $derived.with seems to be the ideal approach then.

@D-Marc1
Copy link

D-Marc1 commented Dec 29, 2023

Personally, I still think my original proposal of $derived() only allowing a function passed in is the most ideal. It's very confusing for someone coming into Svelte for the first time seeing this: const countDoubled = $derived(count * 2), as this is not allowed in vanilla JS.

The Svelte 5 literature constantly states that the reason for their changes are to reduce the learning curve, so why go against that when it comes to $derived()? I still don't get why the Svelte core team thinks that this non-standard syntax is worth it all to save four characters for a single line: const countDoubled = $derived(() => count * 2). I don't see what's so bad about this. Not to mention, the current $derived() syntax adds four extra characters for multiline, as you need to wrap it in an IIFEE, so it ends up being a wash, anyway.

Current $derived() multiline

const countDoubled = $derived((() => {
  return width * height
})())

Function argument proposal for $derived() multiline

const countDoubled = $derived(() => {
  return width * height
})

@MrWaip
Copy link

MrWaip commented Jan 5, 2024

This is definitely a necessary feature.

We have a ton of derived stores that compute complex conditions. Without that proposal we will get a problems while migration

@TGlide
Copy link
Member

TGlide commented Jan 5, 2024

This is definitely a necessary feature.

We have a ton of derived stores that compute complex conditions. Without that proposal we will get a problems while migration

Why is it necessary? It's just a syntax change, you can do what's proposed with a different syntax (IIFEs).

@kyle-n
Copy link

kyle-n commented Jan 5, 2024

Why is it necessary? It's just a syntax change, you can do what's proposed with a different syntax (IIFEs).

It can be done with IIFEs, but I’ll restate my post from earlier in this thread and say IIFEs are an awkward, verbose way to code. They go against the whole point of Svelte, to write less code.

@TGlide
Copy link
Member

TGlide commented Jan 8, 2024

It can be done with IIFEs, but I’ll restate my post from earlier in this thread and say IIFEs are an awkward, verbose way to code. They go against the whole point of Svelte, to write less code.

I'm not against the change, but saying it is necessary is a stretch. That's all I'm getting at 🙂

@pothos-dev
Copy link

I think allowing both callback and expression syntax would be best.

In my own experience, derived signals that produce functions are very rare, so I think it's acceptable to force developers to add another indirection for this rare case:

// expression syntax for simple things
let area = $derived(width * height)

// callback syntax for long-winded things
let complicatedArea = $derived(() => {
   let taylorFactors = [1, 2, 3]
   let widthPercent = a^2 + b^2
   let width = Mat(taylorFactors).T * widthPercent
   return width * height
})

// nested callback syntax for returning functions from derived blocks
let logAreaFn = $derived(() => {
   let area = width * height
   return () => console.log(area)
})

when the compiler sees a function as the expression within $derived, it evaluates it when any states directly inside this function change. Any additional functions that are nested within the outer function would just be treated as normal closures.

There are many different signal implementations currently in the wild, so far every one that I have seen uses the callback syntax for derived signals. In not allowing this, Svelte would break with the majority of the web ecosystem, which also makes it more confusing for developers coming from other frameworks. There has to be a strong reason to that, and I don't think we have one here.

@enyo
Copy link

enyo commented Jan 16, 2024

It's very confusing for someone coming into Svelte for the first time seeing this: const countDoubled = $derived(count * 2), as this is not allowed in vanilla JS.

This is definitely allowed in vanilla JS. Nothing wrong here. You evaluate an expression and pass it to $derived, which turns it into a reactive variable.

That you don't have the overhead of thinking "how does the reactive variable get updated then" (like in react or vue) is a good thing and the power of having a preprocessor like svelte does.

@D-Marc1
Copy link

D-Marc1 commented Jan 16, 2024

This is definitely allowed in vanilla JS. Nothing wrong here. You evaluate an expression and pass it to $derived, which turns it into a reactive variable.

Wrong, it’s undoubtedly not allowed in Vanilla JS.

@enyo
Copy link

enyo commented Jan 16, 2024

@D-Marc1 it undoubtedly is. See here: https://codepen.io/enyo/pen/NWJdVQa

@brunnerh
Copy link
Member

It's just a function call, of course it's allowed, it merely has no special properties on its own.
But the same is true for $state, you cannot have reactivity based on assignments in plain JS.

This being different from other frameworks is not a good argument either.
If Svelte weren't significantly different from other frameworks, there wouldn't be any point in having it in the first place.

@D-Marc1
Copy link

D-Marc1 commented Jan 16, 2024

@D-Marc1 it undoubtedly is. See here: https://codepen.io/enyo/pen/NWJdVQa

Nice try, but nope, it’s not. Thank you for proving my point that this isn’t allowed. In your example, you just returned a single value, despite how you presented it. It’s no different than just plugging in 8 as the parameter value.

@D-Marc1
Copy link

D-Marc1 commented Jan 16, 2024

It's just a function call, of course it's allowed, it merely has no special properties on its own. But the same is true for $state, you cannot have reactivity based on assignments in plain JS.

$state() actually resembles a normal function in JavaScript though, where you just pass in a single parameter.

@TGlide
Copy link
Member

TGlide commented Jan 16, 2024

It's just a function call, of course it's allowed, it merely has no special properties on its own. But the same is true for $state, you cannot have reactivity based on assignments in plain JS.

$state() actually resembles a normal function in JavaScript though, where you just pass in a single parameter.

You're also only passing in a single parameter to $derived though... it's the result of the expression.

When it's said to be allowed in JS, it's meant to be syntactically valid. Of course the way it'll work under the hood will be a bit different, that's always how Svelte worked.

So what exactly do you mean by it's not valid JS? How it wouldn't make sense at a glance for that expression to be re-evaluated afterwards because you can't have a reference to it without a fn? If so, I don't see the problem, unless you take issue with all the other "magic" that runes brings with it.

@D-Marc1
Copy link

D-Marc1 commented Jan 16, 2024

You're also only passing in a single parameter to $derived though... it's the result of the expression.

When it's said to be allowed in JS, it's meant to be syntactically valid. Of course the way it'll work under the hood will be a bit different, that's always how Svelte worked.

So what exactly do you mean by it's not valid JS? How it wouldn't make sense at a glance for that expression to be re-evaluated afterwards because you can't have a reference to it without a fn? If so, I don't see the problem, unless you take issue with all the other "magic" that runes brings with it.

The difference is that passing a value to $state() is syntactically comparable to passing a parameter value to function, whereas evaluating values as parameter values is confusing and unnatural syntax in JavaScript.

@MotionlessTrain
Copy link
Contributor

MotionlessTrain commented Jan 16, 2024

Expressions are used more often as parameter values in JavaScript, e.g.:

console.log(a, b, c, (a && b) || c) // Debugging a Boolean expression

throw new Error('Illegal argument: ' + param)

const extension = file.substring(file.indexOf('.') + 1)

// etc.

$derived is not really an exception to that, syntax wise
To me, the behaviour of $derived looks more natural than a callback function, because with a callback function, it looks to me to be called at a later stage (e.g. async, or when a certain event happens, like how onMount works), so it looks strange to me that the variable you are assigning the result to would have a value already

@D-Marc1
Copy link

D-Marc1 commented Jan 17, 2024

Expressions are used more often as parameter values in JavaScript, e.g.:

console.log(a, b, c, (a && b) || c) // Debugging a Boolean expression

throw new Error('Illegal argument: ' + param)

const extension = file.substring(file.indexOf('.') + 1)

// etc.

$derived is not really an exception to that, syntax wise To me, the behaviour of $derived looks more natural than a callback function, because with a callback function, it looks to me to be called at a later stage (e.g. async, or when a certain event happens, like how onMount works), so it looks strange to me that the variable you are assigning the result to would have a value already

To play the devil's advocate, the best argument for keeping the current $derived(2 * count) syntax is actually because in all of your examples, it would be better to just store it in a separate variable anyway.

@TGlide
Copy link
Member

TGlide commented Jan 17, 2024

The difference is that passing a value to $state() is syntactically comparable to passing a parameter value to function, whereas evaluating values as parameter values is confusing and unnatural syntax in JavaScript.

I see, I get that! Especially because the compiler does convert the expression inside $derived into a function (check the JS output on the Svelte 5 preview REPL).

The discussion that becomes if it is worth it nonetheless.

One argument I'd give in favor of the expression syntax, is to make it more immediately obvious that it's different from $effect. Instead of running something, it returns a value that's computed. Sure, the syntax may be a bit magical, but it helps describe what it's doing.

@Not-Jayden
Copy link
Contributor Author

Not-Jayden commented Jan 20, 2024

#10240 There's now a PR implementing this as $derived.fn 🙂

EDIT: ...aaaaand it's closed. This issue should probably be closed too if that's the final decision.

@nicolaic
Copy link

From the perspective of someone who wants to learn Svelte after runes were announced, I don't think the current design of $derived is well considered. I can understand the reasoning behind it, but I cannot in good faith agree with them.

I've summarized my thoughts into a bullet list:

  • $derived is the only rune that deviates from standard JS
  • It's unintuitive, especially to those already familiar with signals/runes
  • It doesn't adhere to the principle of least astonishment
  • Multi-line derived value feels like a second class citizen or afterthought
  • Documentation only shows how to do simple expressions, but not multi-line
  • Current API feels catered to example snippets rather than real world use cases

Ideally I think it would be best to change $derived to accept a callback rather than an expression, as it would make the API more cohesive and intuitive. I understand this is highly unlikely to happen, but I think it would be the best long term decision, because I don't think saving 4-6 characters for the simplest of cases is worth the sacrifice of everything else mentioned above.

@Not-Jayden
Copy link
Contributor Author

Closing this issue now that #10240 is merged. Only thing remaining is bike-shedding around naming happening in #10334

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests