Aujourd’hui, je vous propose de découvrir ensemble le principe des signaux, une nouvelle primitive qui permet de gérer la réactivité dans nos applications. Les signaux ont fait leur apparition dans des bibliothèques telles que SolidJS, Preact et plus récemment dans Angular. Mais qu’est-ce que c’est exactement et pourquoi est-ce intéressant pour la conception de nos composants ?
00:00 Découverte des signaux
07:10 Exemple d'utilisation
13:00 Les autres librairies
14:03 Utilisations des signaux dans React / Preact
20:56 Les signaux dans le JSX de Preact
24:00 Les limitations
29:02 Conclusion
Qu'est ce qu'un signal ?
Un signal est un objet qui représente une valeur changeante au fil du temps qui sera capable d'informer les contextes parents en cas d'utilisation et de les informer automatiquement en cas de changement de valeur.
Commençons par explorer le principe des signaux en dehors de toute bibliothèque. Pour cela, nous utiliserons la librairie @preact/signals-core (Cette librairie n’est pas exclusivement liée à Preact; vous pouvez également l’utiliser pour du JavaScript classique).
Pour créer un signal on va utiliser la méthode signal()
qui prend en paramètre la valeur initiale. On pourra ensuite lire la valeur, ou la modifier à l'aide d'une propriété value
import {signal} from '@preact/signals-core'
const firstname = signal('John')
// Si on veut lire la valeur
firstname.value
// Si on veut changer la valeur
firstname.value = 'Jane'
On pourra aussi s’abonner aux changements de valeur d'un signal à l'aide de la méthode subscribe
qui prendra un callback en paramètre qui sera automatiquement appelé lorsque la valeur du signal change.
import {signal, subscribe} from '@preact/signals-core'
const firstname = signal('John')
firstname.subscribe((name) => {
console.log(`Prénom : ${name}`)
})
Cette méthode retournera une fonction que l'on pourra utiliser pour se désabonner des changements de valeur.
Jusque là rien de très nouveaux. Le vrai pouvoir des signaux va être leur capacité d’informer le contexte parent lorsqu'ils sont lus, ce qui permet la mise en place d'un système d'abonnement automatique à travers 2 fonctions effect()
& computed()
.
Effet de bord avec effect()
Commençons par effect()
, une fonction qui prend en paramètre un callback qui définit un effet de bord. À l’intérieur de cette fonction, on peut accéder à la valeur de nos signaux.
const firstname = signal('John')
effect(() => {
console.log(`Prénom : ${firstname.value}`)
})
La particularité ici est que dès que vous assignez une nouvelle valeur au signal firstname
, le callback de la méthode effect()
sera automatiquement redéclenché.
firstname.value = 'Jane' // log: "Prénom : Jane"
firstname.value = 'Marc' // log: "Prénom : Marc"
Signal dérivé avec computed()
Ensuite, la seconde fonction qui va être intéressante, c’est la fonction computed
qui aura une signature similaire à celle de effect()
, sauf qu’elle retournera une valeur. Par exemple, nous pouvons avoir un premier signal qui contient le prénom, un second signal qui contient le nom, et ensuite créer un signal dérivé à partir de ces 2 signaux.
const firstname = signal('John')
const lastname = signal('Doe')
const fullname = computed(() => {
console.log(`${firstname.value} ${lastname.value}`)
});
Maintenant, si nous décidons d’avoir un effet basé sur fullname
, tout changement automatique du prénom ou du nom déclenchera cet effet.
effect(() => {
console.log(`Nom complet : ${fullname.value}`)
})
firstname.value = 'Jane' // log: Jane Doe
On notera 2 points intéressants sur les signaux dérivés :
- Le code d'un signal dérivé n'est exécuté seulement si le signal dérivé est lu.
- Un signal dérivé n'émet un changement que lorsque sa valeur change et peut donc déclencher un effet moins souvent que les signaux dont il dépend.
const firstname = signal('John')
const size = computed(() => firstname.length)
effect(() => console.log(size.value.length)) // log: 4
firstname.value = 'Jane' // Pas de log, size ne change pas de valeur
firstname.value = 'Janes' // log: 5
Utilisation en JavaScript pur
Les signaux peuvent être intéressants dans l'écriture de code JavaScript classique pour mieux morceler évènement et mutation. On commence par centraliser l'état dans un signal que l'on mute lors de certaines actions utilisateur.
const counter = signal(0)
button.addEventListener('click', () => counter.value++)
Ensuite on définit les mutations dans les effets associés à nos signaux.
effect(() => {
span.innerText = counter.value.toString()
})
L'avantage est qu'il est facile d'ajouter de nouveaux comportements, car il suffit simplement de changer la valeur du signal. L'effet de bord se charge du reste.
reset.addEventListener('click', () => counter.value = 0)
Aussi, on peut utiliser des signaux dérivés pour des effets de bord plus complexes.
const resetTemplate = document.querySelector('#resetButton')
const canReset = computed(() => counter.value > 0)
effect(() => {
if (!canReset.value) {
return;
}
// On ajoute un bouton réinitialisation si le compteur > 0
const resetButton = resetTemplate.content.cloneNode(true).querySelector('button')
resetButton.addEventListener('click', () => counter.value = 0)
button.insertAdjacentElement('afterend', resetButton)
// Quand cet effet est rejoué, on supprime le bouton
return () => {
resetButton.remove()
}
})
Utilisation dans React / Preact
Maintenant, nous allons examiner comment ces signaux peuvent être utilisés dans React ou Preact. La librairie vu précédemment possède des adapter pour React (@preact/signals-react
) et Preact (@preact/signals
)
Le premier avantage réside dans le fait qu'un signal n'est pas soumis aux contraintes des hooks. Un signal peut être défini en dehors d'un composant, puis consommé par plusieurs composants. Cela permet de créer un système d'état centralisé (ce qui n'est pas toujours évident à réaliser avec React ou Preact)
import {signal} from '@preact/signals'
const counter = signal(0)
setInterval(() => {
counter.value++
}, 1000)
const Timer = () => {
return <div>Temps: {counter.value}</div>
}
La particularité est que lorsqu'un composant utilise un signal il va automatiquement s'abonner à ses changements et sera rendu à nouveau dès que la valeur du signal change.
Il est aussi possible de créer des signaux propre à l'instance d'un composant.
import {useSignal} from '@preact/signals'
const Timer = () => {
const counter = useSignal(0)
useEffect(() => {
const interval = setInterval(() => {
counter.value++
}, 1000)
return () => clearInterval(interval)
}, [])
return <div>Temps: {counter.value}</div>
}
On peut aussi créer un signal dérivé dans le cas où on souhaite transformer la valeur à afficher.
import { useSignal, useComputed } from '@preact/signals'
import { useEffect } from 'preact/hooks'
const Timer = () => {
const counter = useSignal(0)
const counterInMinutes = useComputed(() => Math.floor(counter.value / 60))
useEffect(() => {
const interval = setInterval(() => {
counter.value++
}, 1000)
return () => clearInterval(interval)
}, [])
return <div>Temps: {counterInMinutes.value}min</div>
}
Le composant ne sera alors rendu qu'une fois par minutes.
Mutation sans rendu (Preact)
Dans le cadre de Preact les signaux peuvent être utilisés en enfant d'élément JSX.
import { useSignal } from '@preact/signals'
import { render } from 'preact'
import { useEffect } from 'preact/hooks'
const Timer = () => {
const counter = useSignal(0)
useEffect(() => {
const interval = setInterval(() => {
counter.value++
}, 1000)
return () => clearInterval(interval)
}, [])
return <div>Temps: {counter}min</div>
}
Dans ce cas là, le composant ne sera pas rendu à chaque changement de counter
mais Preact liera directement les mutations du DOM aux changements de valeur du signal (ici il modifiera directement le textContent
du nœud texte plutôt que d'utiliser le VirtualDOM pour déterminer les changements à effectuer).