Svelte 5 - Les runes

Présentation

Depuis maintenant quelques temps, Svelte 5 est sorti. Cette nouvelle version apporte quelques nouveautés que je vous laisse découvrir ici.

Utilisateur de Svelte et SvelteKit depuis pas mal de temps (en gros, tous mes derniers projets perso utilisent ce framework), je m’intéresse à ses évolutions. Pas hyper fan de certaines évolutions de SvelteKit, je voudrais voir l’impact des modifications de Svelte 5.

Pour rappel, les grandes forces de Svelte (pour moi) : la simplicité, la montée en compétence rapide (globalement c’est du JS) et le principe de réactivité hyper simple. Rich Harris (créateur de Svelte) avait fait une conférence hyper intéressante que je vous laisse (également) découvrir ici.

C’est quoi des Runes

Pour faire TRES simple, les runes sont un ensemble de fonctions qui vont permettre d’indiquer à Svelte les points de réactivité. Liste non exhaustives :

  • $state : pour créer une variable réactive,
  • $derived : pour créer une variable qui va être dérivée d’une autre variable
  • $effect : pour “faire des choses” (*) suite à une modification d’un entrant,
  • $props : pour récupérer les paramètres d’un composant,
  • Plus d’infos : ici

En Svelte X (X < 5), pour déclarer des variables réactives :

  let count = 10;
  $: doubleCount = count * 2;

il faut remplacer par :

let count = $state(10);
const doubleCount = $derived(count * 2);

De l’extérieur et après une première lecture, les sentiments qui peuvent survenir :

  • “Euh … Ok …”
  • “Pourquoi pas …”
  • “C’est du React ?”

En fait, pour comprendre le changement, il faut écouter les différents développeurs / mainteneurs de Svelte. Avec le temps, les choix effectués sur la mise en place interne de la réactivité n’étaient plus compatibles avec la taille de certaines applications. Le framework était arrivé aux limites et des comportements non voulus survenaient. Bon perso, mes applications étant toutes petites … jamais eu trop de soucis … mais quelques hooks :).

Exemple(s) de cas limites

Gestion des tableaux

Comme indiqué, vu la taille de mes applications, je n’ai jamais été confronté à comportements bizarres (en dehors de mes bugs :). Mais j’ai du parfois faire quelques adaptations dans mon code pour que les choses fonctionnent.

Un premier exemple : la gestion des tableaux.

<script>
    let myTab = [];

</script>
<ul>
    {#each myTab as m}
        <li> { m } </li>
    {/each}
</ul>
<button on:click={ () => { myTab.push("a"); }  }>Test</button>

Ici normalement, un clic sur le bouton devrait ajouter un élément à la liste. Malheureusement, cela ne marche pas. Pourquoi ? Parce qu’en Svelte 3/4, le framework détecte un changement de valeur d’une variable sur une affectation. Ici, la variable ne change pas donc pas de mise à jour de la liste.

Pour palier à cela, il faut “forcer” la mise à jour :

<script>
    let myTab = [];

</script>
<ul>
    {#each myTab as m}
        <li> { m } </li>
    {/each}
</ul>
<button on:click={ () => { myTab.push("a"); myTab = myTab; }  }>Test</button>
<><script>
    let myTab = [];

</script>
<ul>
    {#each myTab as m}
        <li> { m } </li>
    {/each}
</ul>
<button on:click={ () => { myTab.push("a"); myTab = myTab; }  }>Test</button>

J’ai ajouté une affectation à la fin. Pas grave, mais c’est typiquement le genre de choses que l’on oublie facilement et qui donne le fameux : “Mer, pourquoi çà marche pas, put!”

En Svelte5, il faut plutôt faire cela :

<script>
    let myTab = $state([]);

</script>
<ul>
    {#each myTab as m}
        <li> { m } </li>
    {/each}
</ul>
<button onclick={ () => { myTab.push("a"); }  }>Test</button>

La seule différence est qu’à l’initialisation de myTab, j’indique qu’il s’agit d’un état. En faisant cela, Svelte va lors de la génération du code JS crée un “proxy” autour de la variable:

let myTab = $.proxy([]);
// En svelte 4 : let myTab = [];

Ce proxy va être en capacité de détecter le changement sans passer par une réaffectation.

Effects

En svelte 4, pour “faire des choses” suite à des modifications de variables (plus complexe qu’un simple calcul), il est possible d’utiliser une syntaxe du type :

$: console.log(myTab);

Ce qui marche très bien dans le cas présent, car Svelte détecte facilement que mon code doit réagir à un changement de myTab. Mais disons pour l’exemple que je change mon code pour cela :

$: () => { console.log(myTab); }

Cela ne fonctionne plus. Pourquoi ? Parce que dans ce cas Svelte, ne détecte pas que le code est “dépendant” de myTab et doit donc réagir en cas de changement.

Pour choses complexes, je me suis déjà vu faire :

let myvar1 = 0;
let myvar2 = 0;
let myvar3 = 0;

const fn(p1: number, p2: number, p3: number) {
    [...]
}
$: fn(myvar1, myvar2, myvar3)

Ca marche, mais ce n’est pas forcément ce que l’on attend …

En Svelte 5, la solution est :

<script>
    let myTab = $state([]);

        $effect(() => {
            if(myTab.length > 3) {
                console.log("Too long", myTab.length);
                myTab = [];
            }
            console.log(myTab)
        });
</script>
<ul>
    {#each myTab as m}
        <li> { m } </li>
    {/each}
</ul>
<button onclick={ () => { myTab.push("a"); }  }>Test</button>

A noter que j’ai été obligé de mettre du code car sinon Svelte générait une erreur :
Your `console.log` contained `$state` proxies. Consider using `$inspect(...)` or `$state.snapshot(...)` instead Big brother is watching you.

Et donc :

<script>
    let myTab = $state([]);

        $effect(() => {
            if(myTab.length > 3) {
                console.log("Too long", myTab.length);
                myTab = [];
            }
        });

    $inspect(myTab);
</script>
<ul>
    {#each myTab as m}
        <li> { m } </li>
    {/each}
</ul>
<button onclick={ () => { myTab.push("a"); }  }>Test</button>

Le/La rune inspect donne une vision des évolutions :

C’est du React

Je ne suis pas expert React mais de mémoire (et si tout n’a pas changé), React permet d’utiliser des useState, useEffect, etc… La syntaxe est donc proche. Une différence notable (du moins d’après mes lectures … je ne suis pas allé vérifier) et dans l’implémentation :

  • React : en cas de changement, met à jour tout ou partie du DOM via l’utilisation d’un virtual DOM,
  • Svelte 5 : utilise les “Signals” et son principe de “compilation” pour chainer les modifications et gagner en efficacité.

Les signals semblent quelque peu à la mode (mais finalement d’assez ancien). Angular les utilise, Solid aussi (que je connais pas …). L’objet ici n’est pas de décrire les signals donc je laisse ca à d’autres : ici

Un peu de pratique !

Inspiration

Lors d’un phase de veille, je suis tombé sur cet article. Et je me suis dit que c’est une bonne idée d’ajouter cela dans mes outils (Bon … en vrai, quand je conduis, je ne bois pas … mais bon …). Merci donc à Mads Stoumann.

IMPORTANT : je n’ai pas remis en cause les algos … ni les formules :)

Première version

Je rentre pas le détail de la partie front mais voici une première version “fonctionelle” :

    // Vars
    // -- Le poids
    let weight = $state(67); // Poids en Kilo
    // -- distribution
    let waterDistribution = $state(0.58);
    // -- Alcool
    let totalAlcoolUnits = $state(0);
    // -- BAC
    let currentBac = $state(0);
    // -- Can Drive
    let canIDrive = $state(-1); // -1 : non calculé
    // -- Sobre
    let soberIn = $state(0);

    // Gestion du calcul
    $effect(() => {
        // BAC = (Alcohol in grams / (Body weight in grams * Body water constant)) * 100
        currentBac = (((totalAlcoolUnits * 10) / ((weight * 1000) * waterDistribution)) * 100)
        // Peut conduire ?
        canIDrive = currentBac < 0.05 ? 1 : 0;
        // Temps pour degriser ?
        soberIn = (currentBac / 0.015);
    });

J’ai crée mes différentes variables qui sont pour les premières “bindés” à des formulaires :

  <select id="waterDistribution" name="waterDistribution" bind:value={ waterDistribution } >
        {#each waterDistributions as c }
            <option value={ c.value } > { c.label } </option> 
        {/each}
   </select>

et quand une valeur change la fonction “effect” est appelée ce qui met à jour les 3 suivantes ce qui met à jour l’interface.

Ca fonctionne.

Par contre, sur la base de la documention (when not to use $effect) je ne respecte pas les bonnes pratiques. $effect devrait être utilisé pour les modifications de DOM ou autres grosses manip. Ici, il serait possible de passer soit :

  • des fonctions $derived,
  • directement par des écoutes.

Note: Svelte nous aide et nous guide. Si vous mettez une variable à jour sans passer par $state : soberIn is updated, but is not declared with $state(...). Changing its value will not correctly trigger updates

$derived

Cela donne :

    // -- Le poids
    let weight = $state(67); // Poids en Kilo
    // -- distribution
    let waterDistribution = $state(0.58);
    // -- Alcool
    let totalAlcoolUnits = $state(0);
    // -- BAC
    let currentBac = $derived((((totalAlcoolUnits * 10) / ((weight * 1000) * waterDistribution)) * 100));
    // -- Can Drive
    let canIDrive = $derived(currentBac < 0.05 ? 1 : 0);
    // -- Sobre
    let soberIn = $derived(currentBac / 0.015);

Et ca marche tout pareil.

Updates

Dans plusieurs articles, conférences, tutos, j’ai noté qu’ils conseillaient d’utiliser autant que possible les éléments JS et les évènements associés.

    // Vars
    // -- Le poids
    let weight = $state(67); // Poids en Kilo
    // -- distribution
    let waterDistribution = $state(0.58);
    // -- Alcool
    let totalAlcoolUnits = $state(0);
    // -- BAC
    let currentBac = $state(0);
    // -- Can Drive
    let canIDrive = $state(-1); 
    // -- Sobre
    let soberIn = $state(0); 

    /**
     * La fonction de calcul
     */
    const calculate = () => {
        // BAC = (Alcohol in grams / (Body weight in grams * Body water constant)) * 100
        currentBac = (((totalAlcoolUnits * 10) / ((weight * 1000) * waterDistribution)) * 100)
        // Peut conduire ?
        canIDrive = currentBac < 0.05 ? 1 : 0;
        // Temps pour degriser ?
        soberIn = (currentBac / 0.015);
    }
    // Pour que cela soit fait une première fois
    calculate();

Ca marche aussi.

Le “defaut” est que via cette solution, il faut le faire une première fois que cela donne un premier résultat. Avec $derived, le “calcul” est fait immédiatement de part la nature même du fonctionnement.

Passage par une classe

Par contre, bon … J’aime pas trop (même pour ce genre d’exercice) que mes formules soient en dur dans l’interface. Pour les tests unitaires c’est pas fou :). Donc en Svelte 4, je mettais dans une classe du type :

export class CanIDrive {

    /**
     * Le poids (nan ?)
     */
    public weight : number = 67;
    /**
     * Comment se repartit l'eau
    */
   public waterDistribution : number = 0.58;
   /**
    * Le nombre d'unité
    */
   public totalAlcoolUnits : number = 0;

   /**
    * BAC
    */
   public get currentBac() {
    return (((this.totalAlcoolUnits * 10) / ((this.weight * 1000) * this.waterDistribution)) * 100);
   }

   /**
    * THE question
    */
   public get canIDrive() {
    return this.currentBac < 0.05 ? 1 : 0;
   }

   /**
    * Je peux partir ?
    */
   public get soberIn() {
    return this.currentBac / 0.015
   }
}

Le code devenant :

<script>
    // Init de la classe
    let data = new CanIDrive();
    // Hooks pour indiquer à Svelte que quelque chose à changer et forcer le refresh des getter
    $: data = data;
</script>
<!-- ... -->
 <select id="waterDistribution" name="waterDistribution" bind:value={ data.waterDistribution } >
    {#each waterDistributions as c}
        <option value={c.value}> {c.label} </option>
    {/each}
</select>

Je pense que les vrais pros de Svelte hurlent en voyant cela mais cela fonctionne (au moins dans mes cas simples.)

Naivement, je me suis dis que pour passer en Svelte 5, il suffirait de :

let data = $state(new CanIDrive());

Et la, j’ai perdu beaucoup de temps … A me demander ce que je faisais mal. A tester plusieurs choses mais rien à faire, via une classe tel que définie ici cela ne fonctionne pas : quand modifie une valeur via le formulaire, l’instance n’est pas changée. Pourtant d’après la doc : If $state is used with an array or a simple object, the result is a deeply reactive state proxy. Donc dans ma compréhension cela devrait fonctionner …

A titre d’exemple, cela fonctionne (dans le sens où le binding avec le formulaire fonctionne) :

    let data = $state({
        weight: 67,
        waterDistribution: 0.58,
        totalAlcoolUnits: 0,
        currentBac: 0,
        canIDrive: 0,
        soberIn : 0
    })

J’ai finalement trouvé en … lisant la doc (partie avancée quand même):

  • Reactive class,
  • Getter and Setter
    et retrouvé une trace dans le doc de preview : Only plain objects and arrays are made deeply reactive by wrapping them with Proxies.

En Javascript, class et objet ne sont pas la même chose donc pour que cela fonctionne et que je puisse utiliser une classe, il faut deux choses :

  • Changer le nom du fichier pour intégrer svelte dedans (que le compilateur reconnaisse le fichier),
  • Passer les propriétés en $state.

Ce qui donne :

export class CanIDrive {

    /**
     * Le poids (nan ?)
     */
    public weight : number = $state(67);
    /**
     * Comment se repartit l'eau
    */
   public waterDistribution : number = $state(0.58);
   /**
    * Le nombre d'unité
    */
   public totalAlcoolUnits : number = $state(0);

   // [...]
}

Et bim : ca marche. Comme quoi lire la doc …

Il est possible également d’utiliser $derived mais à noter que cela n’est pas nécessaire. Les propriétés (getter) sont directement réactives.

Un effet …

Dans la série, habitude de vieux. Dans mon code, j’ai souvent un truc du genre :

<DebugComponent>
    { JSON.stringify(data) }
</DebugComponent>

Le composant Debug ayant le bon goût de ne pas s’afficher en mode prod. Cela dit, là, plus rien n’apparait. En effet, Svelte entoure le propriétés d’un proxy qui est +/- une méthode donc … JSON.stringify() ne retourne plus rien. Pour le moment, la seule solution de contournement est pas hyper pratique :

    public toJSON() {
        return {
            "weight": this.weight,
            "waterDistribution": this.waterDistribution,
            "totalAlcoolUnits": this.totalAlcoolUnits,
            "currentBac": this.currentBac,
            "canIDrive": this.canIDrive,
            "soberIn": this.soberIn
        }
    }

Pas fou …

Ajout d’un tableau de conso

Pour le moment, la saisie des unités d’alcool se fait la main. Ce serait plus sympa de pouvoir dire ce que l’on a consommé (bon … si on n’est plus capable de le faire c’est peut-être un signe). Je vais noter ici les quelques points “Svelte 5”.

  • Utilisation d’une classe : pour saisir les consommations j’ai utilisé une classe avec des runes en guise de variables,
    • J’en ai profité pour comme dans l’exemple utiliser les getter / setter pour valider des règles :
      public set percent(value: number) {
      this._percent = Math.max(0, Math.min(100, value));
      }
  • La classe initiale intègre un tableau qui est directement un $state :
    public consos : CanIDriveConsommation[] = $state([]);
    • ce qui permet de le modifier directement sans affectation : this.consos.push(new CanIDriveConsommation());
    • Il est possible d’utiliser $derived directement dans la classe pour faire une somme sur les consommations :
      public totalAlcoolUnits: number = $derived.by(() => {
      return this.consos.reduce((acc, current) => acc + current.unit, 0);
      })
  • Pour passer des propriétés à un component, il faut utiliser $props()
    • Il est alors possible de typer plus facilement les propriétés,
    • de gérer les propriétés supplémentaires (non utilisé dans ce cas : voir ici),
      • J’ai utilisé dans le cas d’un autre projet const { children, ...others } : HTMLButtonAttributes = $props();
        let { data, onclick } : { data: CanIDriveConsommation, onclick : () => void } = $props();

Bilan

Honnêtement sur la partie spécifique Runes, en tant que développeur du dimanche, cela ne change pas grand chose. C’est une syntaxe et entre $: ... et $derived … la montée en compétence est assez similaire.

LE point qui me gêne : le fait que les classes doivent intégrer des runes pour être réactive. Je n’ai pas encore trouvé de solutions pour contourner cela … Svelte 6 ?

Sources