speedster-js Guide

Réactivité

Il n'y a rien à déclarer. Ce qui est réactif se déduit de la façon dont vous écrivez, et le compilateur le dit quand il n'est pas d'accord.

<script lang="ts">
  let compteur = 0                    // état : il est réaffecté
  const doubled = compteur * 2        // dérivée : elle lit un état
  const titre = 'Accueil'             // constante : rien ne la fait bouger
  const incrementer = () => compteur++
</script>

<button on:click={incrementer}>{compteur} → {doubled}</button>

Une seule règle gouverne tout : la réactivité se fonde sur l'affectation.


Les trois natures

Nature Ce qui la produit Ce qu'elle devient
État Un let réaffecté quelque part — script ou balisage Un signal
Dérivée Un const qui lit un état au moment de son évaluation Une valeur mémoïsée, recalculée à la demande
Constante Tout le reste Une variable ordinaire, sans un octet de code réactif

Le balisage compte comme un lieu d'écriture : on:click={() => compteur++} suffit à faire de compteur un état, même si le script ne le réaffecte jamais.

« Lit à l'évaluation » veut dire quelque chose de précis

let compteur = 0
compteur = 1

const doubled = compteur * 2               // dérivée : lu tout de suite
const lire = () => compteur                // fonction : lu plus tard, à l'appel

doubled doit se recalculer quand compteur change. lire n'a rien à recalculer : elle relira au moment où on l'appellera. Cette distinction — lecture immédiate contre lecture différée — est ce qui évite de mémoïser des fonctions pour rien.

Un rappel exécuté sur-le-champ ne diffère rien. Les méthodes des tableaux et des chaînes — filter, find, map, reduce, some, every, sort, replace… — appellent leur rappel avant de rendre la main, et une fonction appelée dès sa définition aussi :

const PAQUETS = [{ os: 'windows' }, { os: 'linux' }]
let systeme = ''

const recommande = PAQUETS.find((p) => p.os === systeme)  // dérivée
const double = (() => systeme.length * 2)()                // dérivée
const actions = { choisir: () => { systeme = 'linux' } }   // constante : un objet de fermetures

Quand le compilateur ne sait pas si un rappel est exécuté tout de suite — trier(liste, () => cle), une fonction à vous ou d'une bibliothèque —, il ne fige pas la valeur en silence : il le signale.

deferred-reactive-read
« cle » est lu dans un rappel dont le compilateur ne sait pas s'il est exécuté tout de suite…

Passez l'état en argument de l'appel (trier(liste, cle)), ou enveloppez la valeur dans computed(() => trier(liste, () => cle)), importé du runtime, pour la suivre. Si la lecture doit avoir lieu à l'appel, écrivez une fonction. Les rappels connus pour être différés — effect, onMount et les autres imports de speedster, setTimeout, then, addEventListener… — ne sont pas signalés.

Chaînage

let n = 0
n = 1
const a = n + 1        // dérivée
const b = a * 2        // dérivée d'une dérivée

Une dérivée est paresseuse : elle ne calcule qu'à la lecture, et seulement si une source a réellement changé de valeur. Une dérivée qui retombe sur le même résultat n'invalide pas ses propres dépendants.


Le piège, et il n'y en a qu'un

taches.push(nouvelle)          // ❌ rien ne se met à jour
taches = [...taches, nouvelle] // ✅

objet.champ = 'x'              // ❌
objet = { ...objet, champ: 'x' } // ✅

La réactivité ne fait aucune observation profonde des objets. C'est ce qui garde le runtime petit et prévisible : pas de proxy, pas de piège sur chaque propriété, pas de coût caché sur les structures que vous manipulez.

Le prix de ce choix est ce piège — alors il est signalé au build :

mutation-without-reassignment
« taches » est muté sans être réaffecté : le balisage ne se mettra pas à jour.

Une écriture qui retombe sur la même valeur ne notifie rien — l'égalité est Object.is. taches = taches ne réveille donc personne : il faut une nouvelle référence.

taches.push(nouvelle)
taches = [...taches]   // copie superficielle : un tableau de références, c'est peu

Pour une structure où même cela coûte, descendez d'un cran et tenez le signal vous-même, avec l'égalité désactivée :

import { signal } from '@ajustor/speedster-runtime'

const taches = signal<Tache[]>([], { equals: false })

const ajouter = (tache: Tache) => {
  taches.peek().push(tache)
  taches.set(taches.peek())   // notifie malgré la référence identique
}

À réserver aux cas mesurés : l'égalité est ce qui empêche un recalcul inutile de se propager à tout l'arbre.


Une collection à clés

Pour une liste d'éléments identifiables, collection retourne l'écriture : on désigne l'élément par sa clé, et on dit ce qui change.

import { collection } from '@ajustor/speedster'

const taches = collection<Tache>([], { key: (tache) => tache.id })

taches.add({ id: 1, libelle: 'Écrire', faite: false })
taches.update(1, (tache) => ({ ...tache, faite: true }))
taches.remove(1)

À comparer avec ce que le même changement demande sur un tableau :

taches.update((precedentes) =>
  precedentes.map((tache) => (tache.id === id ? { ...tache, faite: true } : tache)),
)

Trois choses changent. L'écriture dit ce qu'elle fait. Ajouter, modifier et retirer sont en temps constant au lieu de reconstruire le tableau. Et le piège du paragraphe précédent disparaît sur ce cas : il n'y a plus d'affectation à oublier.

taches() les éléments, dans l'ordre d'insertion — lecture réactive
taches.get(clef) un élément, et seulement lui : un composant qui l'affiche ne se réexécute pas quand un voisin change
taches.keyOf(tache) la clé, telle que la collection la calcule
add, set, update, remove, clear les écritures
has, size lectures réactives

Dans un gabarit, la clé de la liste vient de la collection :

<ul>
  {#each taches() as tache (taches.keyOf(tache))}
    <li>{tache.libelle}</li>
  {/each}
</ul>

La clé est le point de conception

key est proposée d'abord parce qu'une clé métier — tache.id — survit à un aller-retour par le réseau. Omise, la clé est générée : un objet reçoit une clé attachée à sa référence, une valeur primitive est sa propre clé.

Ce que la clé générée ne fait pas, et c'est la raison d'être de key : reconnaître deux objets égaux venus de deux chargements. Après un fetch, les objets reviennent égaux mais neufs, donc avec des clés neuves — tout serait détruit puis reconstruit, et le focus, la sélection et l'état des champs partiraient avec. Une collection alimentée par le réseau donne sa fonction key.


Mises à jour groupées

Plusieurs écritures dans le même souffle ne produisent qu'un seul rendu :

const reinitialiser = () => {
  filtre = ''
  page = 1
  selection = []
}

Le graphe marque ses dépendants et programme un vidage en microtâche. Le DOM n'est donc touché qu'une fois, après le gestionnaire, quel que soit le nombre d'écritures.

Pour forcer le vidage — un test, une mesure juste après une mise à jour :

import { flushSync } from '@ajustor/speedster-runtime'

compteur = 5
flushSync()
console.log(element.textContent)   // à jour

Effets

Un effet sert à ce qui n'est pas du rendu : une requête, un abonnement, une synchronisation avec une bibliothèque extérieure.

<script lang="ts">
  import { effect } from '@ajustor/speedster-runtime'

  export let identifiant: string
  let tache: Tache | null = null

  effect(() => {
    const cible = identifiant
    let annule = false
    void charger(cible).then((valeur) => {
      if (!annule) tache = valeur
    })
    return () => { annule = true }
  })
</script>

Trois choses à savoir :

  1. La première exécution est différée au prochain vidage, donc après la construction du DOM environnant. Un effet peut mesurer, ou déplacer le focus.
  2. Les dépendances sont relevées à l'exécution. Lire identifiant suffit à s'y abonner ; il n'y a pas de liste à tenir.
  3. La fonction retournée nettoie, avant chaque réexécution et à la libération. C'est là qu'on annule.

Pour lire sans s'abonner :

import { untrack } from '@ajustor/speedster-runtime'

effect(() => {
  const cible = identifiant          // suivi
  const page = untrack(() => numero) // lu, mais ne déclenchera rien
})

onCleanup(fn) enregistre un nettoyage sur la portée courante, sans créer d'effet.


Contexte

Pour transmettre une valeur à travers plusieurs niveaux sans la faire passer de propriété en propriété :

// theme.ts
import { createContext } from '@ajustor/speedster-runtime'
export const Theme = createContext<'clair' | 'sombre'>('clair')
<!-- Application.spd -->
<script lang="ts">
  import { provide } from '@ajustor/speedster-runtime'
  import { Theme } from './theme.js'
  provide(Theme, 'sombre')
</script>
<!-- N'importe où en dessous -->
<script lang="ts">
  import { inject } from '@ajustor/speedster-runtime'
  import { Theme } from './theme.js'
  const theme = inject(Theme)
</script>

Le contexte suit l'arbre de possession, pas l'arbre du DOM : un fragment nommé transmis à un composant lointain garde le contexte de l'endroit où il a été écrit.


Les primitives, si vous en avez besoin

Le compilateur produit ces appels pour vous. Elles restent exportées pour le code hors composant — une logique partagée, un magasin de données :

import { signal, computed, effect, batch, untrack } from '@ajustor/speedster-runtime'

const compteur = signal(0)
const double = computed(() => compteur() * 2)

effect(() => console.log(double()))

compteur.set(1)
compteur.update((n) => n + 1)
compteur.peek()          // lit sans s'abonner

batch(() => {            // un seul rendu pour les deux écritures
  compteur.set(10)
  compteur.set(20)
})

Un magasin écrit ainsi s'utilise dans un composant comme n'importe quel import : le compilateur voit un appel de fonction, et pose un effet là où il est lu.

collection est de la même famille, pour une liste d'éléments identifiables — voir Une collection à clés.