sveltejs / svelte

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

Svelte 5: A function or rune that allows destructuring of proxies or objects with getters while keeping reactivity. #11441

Closed HighFunctioningSociopathSH closed 2 weeks ago

HighFunctioningSociopathSH commented 2 weeks ago

Describe the problem

Thanks to the work of @trueadm. we are currently able to destructure a $derived object while preserving its fine-graned reactivity. But this only works if $derived is used and I was wondering if there was a way to encapsulate this feature of $derived rune in a separate utility function. currently, when we create a proxy or an object with getters, that object can not be destructured since if done so it loses its getters and thus its reactivity. But somehow which is unknown to me $derived is capable of handling this issue. This gave me an idea to do the following testFunction.ts

import { untrack } from "svelte";

export function testFunction<T extends Record<string, any>>(object: T) {
  console.log("function ran"); // only gets called once so I know that testFunction is not running again because of $derived.

  return untrack(() => {
    return new Proxy(object, {
      get(target, p, receiver) {
        return `value is => ${target[p as string]}`;
      }
    });
  });
}

Test.svelte

<script lang="ts">
  import { testFunction } from "$lib/hooks/test.svelte.ts";
  import { onMount } from "svelte";

  let testObject = $state({
    property1: "hello",
    property2: 5
  });

  onMount(() => {
    setTimeout(() => {
      testObject.property1 = "bye";
    }, 3000);
  });

  const { property1, property2 } = $derived(testFunction(testObject));

  $inspect(property1);
</script>

As you can see I nullified the effect of $derived by wrapping everything inside of it in an untrack. This way the function won't be called again when things change which makes it behave like a module, and prevents the function from returning a new proxy on each run which ruins reactivity. But I also benefit from the ability of destructuring the output of $derived without losing reactivity.

This also helps with normal modules that want to use the new universal reactivity approach. The only Gotcha that this new approach had was having to return a getter which sucked because the user couldn't destructure the result. for instance in the following example from the documentation accessing count as counter.count is necessary otherwise it won't work.

  export function createCounter() {
    let count = $state(0);

    function increment() {
      count += 1;
    }

    return {
      get count() {
        return count;
      },
      increment
    };
  }
<script>
    import { createCounter } from './counter.svelte.js';

    const counter = createCounter();
</script>

<button onclick={counter.increment}>
    clicks: {counter.count}
</button>

But with my workaround destructuring is possible

export function createCounter() {
  console.log("Note that this log only runs once so the function didn't run again because of $derived.");

  return untrack(() => {
    let count = $state(0);

    function increment() {
      count += 1;
    }

    return {
      get count() {
        return count;
      },
      increment
    };
  });
}
<script lang="ts">
  import { createCounter } from "$lib/hooks/test.svelte.ts";

  const { count, increment } = $derived(createCounter());

  $inspect(count);
</script>

<button onclick={increment}>button</button>

count will update with no problem.

Describe the proposed solution

As you can see, this is not the right way nor the intended way of using $derived so I was wondering If the destructuring feature of $derived could be accessible separately allowing us to destructure basically any js proxy or object that has getters and setter inside of it. for example

export function createCounter() {
    let count = $state(0);

    function increment() {
      count += 1;
    }

    return {
      get count() {
        return count;
      },
      increment
    };
}
<script lang="ts">
  import { createCounter } from "$lib/hooks/test.svelte.ts";

  const { count, increment } = $destructurable(createCounter()); // Perhaps a rune like $destructurable could be used to avoid writing untrack inside the function.

  $inspect(count);
</script>

<button onclick={increment}>button</button>

Importance

would make my life easier

7nik commented 2 weeks ago

I still don't get benefits of let {...} = $destructurable(createCounter()) over let {...} = $derived(createCounter()).

The destructured $derived is, in fact, three-leveled $derived. So, const { count, increment } = $derived(createCounter()); is:

  1. executing the derived: const result = $derived(createCounter());
  2. derived destructuring:
    const props = $derived.by(() => {
    const { count, increment } = result;`
    return [count, increment];
    });
  3. deriving props to avoid firing them when they didn't change:
    const count = $derived(props[0]);
    const increment = $derived(props[1]);

    Step 2 causes reading all the destructured props and thus subscribing to them.

Side note: steps 2 and 3 cannot be merged because in a case like let { nested: { a, b } } = ..., the nested can be a getter with a side effect and thus must be read only once. There are probably other pitfalls, so the current implementation is simple and keeps the original behaviour.

HighFunctioningSociopathSH commented 2 weeks ago

I still don't get benefits of let {...} = $destructurable(createCounter()) over let {...} = $derived(createCounter()).

$derived reruns the entire function again. there are many situations in which you don't want to rerun your entire function just to have what is returned to stay reactive. For example, you might want to create a proxy over an object to add some functionalities to it; If the proxy is created again each time the function runs, signals won't be sent to notify others of the change. Or you might have variable declarations inside that function which if ran again, they would be initialized again with their initial value and make it hard to use a closure to divide different sections of the code. for instance, you might have another function call inside that module that uses the main function's states, now if derived is used then the main function's closure is reset again every time. There are other benefits like being able to use $effect and writing to $state variables that are not possible inside $derived. basically when $derived is used the function should be free of side-effects

All I wanted was to be able to destructure without looking at any dependency like $derived does. But again I don't know exactly how they work under the hood. I just thought that if $derived can make destructuring possible, there might be a way to do it without taking any dependency and rerunning the code whenever those dependencies change, since modules only run once. but then again it's just something I thought I could share to see if it can be made possible or not.

Perhaps the first step in your previously mentioned 3 steps can be ignored and only the second and third steps could be applied where it reads all destructured properties and subscribes to them? Imagine the $destructurable rune only takes the destructured properties and uses $derived on each one of them to subscribe to them and also prevent overfiring. Is the first step necessary for the second and third steps to work?

7nik commented 2 weeks ago

Solution 1: save the object in the intermediate variable and $derived from it. Solution 2: use $derived.by + function returning a getter. Examples.

HighFunctioningSociopathSH commented 2 weeks ago

Wow. Really nice. especially the second solution which only requires one line of code. Thanks. with this answer, and also being able to use $derived on the output and then destructure that, I close this issue since I feel like these 2 solutions handle it pretty well and remove the need for anything else.