Skip to content

[Runes] Allow for fine-grained reactivity in array of primitives and leave data structure intact #9249

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
nickt26 opened this issue Sep 23, 2023 · 4 comments
Labels

Comments

@nickt26
Copy link

nickt26 commented Sep 23, 2023

Describe the problem

From my tests with Svelte 5 runes I have not discovered a way to add fine-grained reactivity to arrays that consist entirely of primitives like string, number or boolean.

Suppose I want to make a re-usable createForm function that allows for fine-grained reactivity, for the purpose of this example I will be keeping the code simple

<script>
function createForm() {
  let username = $state('');
  let password = $state('');
  return {
    values: {
      get username() {
        return username;
      },
      set username(val) {
        username = val;
      },
      get password() {
        return password;
      },
      set password(val) {
        password = val;
      },
    },
  }
};

  let form = createForm();

  $effect(() => {
    console.log('values.username has changed', form.values.username);
  })
</script>

<form>
  <input bind:value={form.values.username} />
  <input bind:value={form.values.password} />
</form>

This example above works like a dream and we have no issues. We could even take the example further and create as many nested objects in here as we need with custom setters and getters. What I'd also like to highlight here is that the use of runes has been completely abstracted from the consumer of the method.

Now let's first take a look at how to create a from with array values that works with fine-grained reactivity

<script>
function createRune(val) {
  let rune = $state(val);
  return {
    get value() {
      return rune;
    },
    set value(val) {
      rune = val;
    }
  }
};

function createForm() {
  let roles = $state([createRune('Admin'), createRune('User')]);
  return {
    values: {
      get roles() {
         return roles;
      },
      set roles(val) {
        roles = val;
      },
    }
  }
};

  let form = createForm();

  $effect(() => {
    console.log('values.roles.0 has changed', form.values.roles[0]);
  })
</script>

<form>
  {#each form.values.roles as role}
    {role.value}
    <input bind:value={role.value} />
  {/each}
</form>

We can see from the working example above that the values inside of the roles array had to be wrapped in an object before we can get some fine-rained reactivity, hence the fact that we need to reference .value inside of the each loop. What I'd also like to mention here is that both the effect and the {role.value} just before the input are updating every time the value of a role is changed (This will become more relevant with the example below using proxies).

Describe the proposed solution

I would like to have this fine grained reactivity on an array of primitives without needing to wrap each value in an object so that I can hide the use of runes from the consumer of the method. I can also see this pattern of wrapping everything in an object becoming quite annoying when working with large objects that contain multiple arrays of primitives and need to be sent back and forth between an API. We would have to constantly remember to undo the effects of using runes which added an extra 'value' prop in this case that we need to remove first.

On top of that I think the idea that you need to wrap all your arrays of primitives in an object is quite an unintuitive way of doing things. It will make the argument for adopting Svelte at my current job way more difficult if I have to tell my colleagues that they need to remember that their arrays of primitives are always being converted to arrays of objects.

What makes this even worse is that each library doing some 'runifying' in the background may do so very differently from another library and so they may not even share the same implementation of 'runifying' their arrays of primitives. One library maintainer may choose to use a prop called 'value' while another maintainer may choose to use a prop called 'val' or anything else.

Alternatives considered

I have tried the following as a workaround to address the issue of wrapping primitives in runes using a Proxy.

<script>
function createRune(val) {
  let rune = $state(val);
  return {
    get value() {
      return rune;
    },
    set value(val) {
      rune = val;
    }
  }
};

function createForm() {
  let roles = $state([createRune('Admin'), createRune('User')]);
  return {
    values: {
      get roles() {
         return new Proxy(roles, {
            get(target, prop) {
              if(isNaN(parseInt(prop))) return Reflect.get(...arguments); // Guarantees that none index props stay intact
              return target[prop].value;
            },
            set(target, prop, value) {
             if(isNaN(parseInt(prop))) return Reflect.set(...arguments); // Guarantees that none index props stay intact
             target[prop].value = value;
             return true;
            },
          })
      },
      set roles(val) {
        roles = new Proxy(roles, {
            get(target, prop) {
              if(isNaN(parseInt(prop))) return Reflect.get(...arguments); // Guarantees that none index props stay intact
              return target[prop].value;
            },
            set(target, prop, value) {
             if(isNaN(parseInt(prop))) return Reflect.set(...arguments); // Guarantees that none index props stay intact
             target[prop].value = value;
             return true;
            },
          })
      },
    }
  }
};

  let form = createForm();

  $effect(() => {
    console.log('values.roles.0 has changed', form.values.roles[0]);
  })
</script>

<form>
  {#each form.values.roles as role}
    {role}
    <input bind:value={role} />
  {/each}
</form>

This works and gives us the fine grained reactivity but with a caveat. The $effect will always run when the input value is changed but the {role} that is placed just before the input in the each loop does not update when it's corresponding input is changed. What this tells me is that the get() inside the proxy is not working correctly but the set() inside the proxy is working as intended.

Importance

would make my life easier

@brunnerh
Copy link
Member

brunnerh commented Sep 23, 2023

I also noticed that issue when trying to port an existing piece of code where a single value was wrapped in a store (#9252).

With runes I could not just do $store but it had to be wrapped in an object as in state.value to expose a get/set property.

@nickt26
Copy link
Author

nickt26 commented Sep 26, 2023

I've found that changing the example with the proxy works when you update the each to the following:

<form>
  {#each form.values.roles as role, i}
    {form.values.roles[i]}
    <input bind:value={role} />
  {/each}
</form>

I also found that when you have 2 each loops that reference the same array like below then the bind will not update correctly unless you change the bind to form.values.roles[i].

FROM:

<form>
  {#each form.values.roles as role}
    <input bind:value={role} />
  {/each}
</form>
<form>
  {#each form.values.roles as role}
    <input bind:value={role} />
  {/each}
</form>

TO:

<form>
  {#each form.values.roles as role}
    <input bind:value={form.values.roles[i]} />
  {/each}
</form>
<form>
  {#each form.values.roles as role}
    <input bind:value={form.values.roles[i]} />
  {/each}
</form>

Interestingly, the bind to form.values.roles[i] does not invalidate the entire array when the value changes and still only causes the $effect to run when the index you are listening to has changed.

@Gin-Quin
Copy link

Seems related to this issue: reactivity is lost on #each variables.

@dummdidumm
Copy link
Member

This now works out of the box with $state which is fine-grained.

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

5 participants