Description
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