sveltejs / svelte

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

Runes: Introduce an `$alias()` rune for cleaner access and assignment to nested reactive properties #9290

Open Not-Jayden opened 11 months ago

Not-Jayden commented 11 months ago

Describe the problem

Svelte 5's reactivity introduces a requirement for at least one layer deep of nesting when reactive state is defined outside of the top level of a component, and in many cases, will likely have even deeper nested reactive properties. The necessity for nested property access adds a level of verbosity which can make the code less readable and harder to manage.

e.g.

import {dimensions} from './dimensions';

function resetDimensions() {
    dimensions.value.width = 1920;
    dimensions.value.height = 1080;
}

dimensions.value.area = 0; // this will error, as area property does not have a setter

const aspectRatio = $derived(dimensions.value.width / dimensions.value.height);

$effect(() => {
     console.log(`Aspect Ratio changed to ${aspectRatio}`);
})

Describe the proposed solution

Introduce a new rune, $alias(), which enables the creation of new variables from reactive properties and the destructuring of nested reactive properties while retaining reactivity/object property reference. The $alias() rune essentially instructs the compiler to replace variable references with the expanded property access wherever the aliased or destructured variable is referenced, ensuring the reactivity of the original properties is maintained.

The Svelte compiler ideally should issue a warning on attempts to assign to referenced properties that don't have setters, as this is a limitation of assigning primitive values in javascript.

e.g. the example above could instead be writted as:

import {dimensions} from './dimensions';

let { value: { width, height, area } } = $alias(dimensions);

function resetDimensions() {
    width = 1920;
    height = 1080;
}

area = 0;  // Svelte should issue a compiler warning as `dimensions.value.area` does not have a setter. JS can't catch this unfortunately.

const aspectRatio = $derived(width / height);

$effect(() => {
     console.log(`Aspect Ratio changed to ${aspectRatio}`);
})

If desired, you should also be able to create variables using variable assignment instead of destructuring as well. i.e.

let width = $alias(dimensions.value.width);
let height = $alias(dimensions.value.height);
let area = $alias(dimensions.value.area);

Alternatives considered

Some alternative naming has been suggested such as $bind(), $link(), $expose(), or $bridge().

Otherwise the alternative is we continue to require always directly accessing nested properties.

Importance

nice to have

JakeBeaver commented 11 months ago

For singular let width = $alias(dimensions.value.width), this should already work with $derived, so it could "just" be a case of allowing multiple derived signals from one $derived rune by destructing, which would certainly be a welcome utility

Not-Jayden commented 11 months ago

For singular let width = $alias(dimensions.value.width), this should already work with $derived, so it could "just" be a case of allowing multiple derived signals from one $derived rune by destructing, which would certainly be a welcome utility

It's not quite the same. The distinction is essentially:

So for:

let width = $derived(dimensions.value.width);

While this keeps width in sync with the reactive value like $alias() would, you cannot assign to it at all. Using $alias(), width acts as a shorthand direct reference to dimensions.value.width, allowing you to directly read and modify it.

Niceadam commented 1 month ago

This would be a nice feature. I am going to add one more case for a similar idea, @alias. This would be very useful in nested loops for example:

  let filters = $state({
    attack: {
      combos: { active: false, input: {comb1: false, comb2: false}, type: InputType.Grid },
      hitType: {
        active: false,
        input: {"Perfect": false, "Good": false, "Bad": false},
      },
      effect: {
        active: false,
        input: {...},
      },
    },
{#each Object.entries(filters) as section, val}
{...}
  {#each Object.entries(val) as [name, val]}
    {...}
    {#each Object.keys(val.input) as input}
       {...}
       bind:checked={filters[section][name].input[input]}
       bind:checked={filters[section][name].active}
       {...}

instead I could just do

{#each ...}
{#each ....}
   {@alias filter = filters[section][name]}
   bind:checked={filter.input[input]}
   bind:checked={filter.active}
   {...}