sveltejs / svelte

Cybernetically enhanced web apps
https://svelte.dev
MIT License
78.21k stars 4.09k forks source link

Passing reactive values to functions #12800

Closed qwuide closed 1 month ago

qwuide commented 1 month ago

Describe the problem

Currently, to pass reactive values to functions or class instances, you have to either:

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

brunnerh commented 1 month ago

Svelte is a compiler, if anything it should just transform a regular call:

$use(createCounter(step));

The proposed API might make sense for read-only reactivity, but if the function needs to be able to set a passed value, you end up with some weird magic.

const createCounter = $hook((step = 1) => {
  // ...
  step = 2; // somehow has an effect on the outside
});

To not accidentally cause side effects, the two-way parameters maybe should also require $bindable.

const createCounter = $hook((step = $bindable(1)) => { ... });

The name probably should be different, it does not hook into anything and there are already too many people proclaiming Svelte 5 to just be React.


Note that you can already use $effect in actions as long as a state reference is preserved. Would not add any additional APIs here. (Post 5.0 a different way of defining things like actions may be introduced anyway.)

<script>
  let text = $state('Hello');
  const sayHello = (element, params) => {
    $effect(() => {
      element.innerText = params.text;
    });
  }
</script>

<button type="button" onclick={() => text = 'Bonjour'}>Say Bonjour</button>
<div use:sayHello={{ get text() { return text } }}></div>

REPL

trueadm commented 1 month ago

Ekk. I don't know what happened there. I tried to delete the spam comment and it seems to have deleted your comment to @qwuide. I apologise for that.

As for this feature request – this is something we've already looked into extensively before and we ultimately felt that this wasn't necessary right now, and introducing it brought about more confusion than it was worth.

qwuide commented 1 month ago

Ekk. I don't know what happened there. I tried to delete the spam comment and it seems to have deleted your comment to @qwuide. I apologise for that.

No worries!

As for this feature request – this is something we've already looked into extensively before and we ultimately felt that this wasn't necessary right now, and introducing it brought about more confusion than it was worth.

Fair enough. In the meantime, is there a recommended way to solve this kind of thing that you suggests over others?

fcrozatier commented 1 month ago

In the meantime, is there a recommended way to solve this kind of thing that you suggests over others?

For now getters and setters are the way to go. You can also have a look at the discussion in #11380 for alternatives

Rich-Harris commented 1 month ago

Yep - just pass () => state functions (and (v) => state = v in the rarer case that a function should be able to set values). It's simple, clear, debuggable, composable, inexpensive, and doesn't require any new surface area