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 :
- 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.
- Les dépendances sont relevées à l'exécution. Lire
identifiantsuffit à s'y abonner ; il n'y a pas de liste à tenir. - 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.