Aller au contenu principal

Cours 2 - Composant dynamique

attention

Dans de nombreux exemples à partir de ce cours, des classes personnelles sont utilisées à la place des classes Tailwind pour alléger le code HTML des exemples. C'est normal qu'en copiant certains exemples, l'apparence de vos pages Web soit un peu moins pétillant 🌶 !

Ce cours aborde l'interaction entre le code TypeScript d'un composant et son code HTML.

  • Changer un état
  • Boucles (Afficher une liste / un tableau)
  • Conditions
  • Événements
  • Formulaires

✏ Changer un état

Au cours 1, nous avions abordé les états (Variable qui peut être affichée dans le HTML d'un composant)

'use client';

import { useState } from "react";

export default function Home() {

const [favoriteColor, setFavoriteColor] = useState("indigo"); // État
const [daysWithoutWorkAccident, setDaysWithoutWorkAccident] = useState(0); // État

return (
<div className="m-2">
<p>Salut. Tu aimes la couleur {favoriteColor}</p>
<p>Il y a eu {daysWithoutWorkAccident} jour(s) sans accident au travail.</p>
</div>
);
}
Affichage d&#39;un état en react

Pour modifier la valeur d'un état, il faudra utiliser le set... de sa déclaration. Exemple :

function whateverFunction() : void{
setFavoriteColor("cramoisi"); // favoriteColor vaudra maintenant "cramoisi"
setDaysWithoutWorkAccident(daysWithoutWorkAccident + 1); // On augmente la valeur de 1
}
attention

⛔ Il est impossible de modifier un état comme ceci :

favoriteColor = "cramoisi";
daysWithoutWorkAccident++;
danger

Lorsqu'on appelle le setter d'un état, sachez qu'il est seulement exécuté à la fin de la fonction.

function someFunction(){

setX(x + 1); // x passera de 4 à 5

console.log(x); // La valeur 4 sera affichée ! ⛔

}

React possède ce comportement car il compile tous les appels de setters en même temps, à la fin de la fonction. Cela permet de générer le nouveau HTML une seule fois, plutôt qu'une fois par appel d'un setter.

Il n'y a pas de solution simple et stratégique pour « attendre » qu'un setter ait été exécuté. Il faut adapter le code :

function someFunction(){

const newValue = x + 1;
setX(newValue); // x passera de 4 à 5

console.log(newValue); // La valeur 5 sera affichée ✅

}
info

Pourquoi est-ce aussi compliqué de manipuler des états ? 😠

Cette manière de procéder aide React à mettre à jour le rendu visuel de la page Web de façon plus performante, sans avoir à recompiler tous les états et tous les éléments de la page Web. Ce mode opératoire indique à React qu'est-ce qui doit changer.

Il existe toutefois des librairies comme Immer qui permet de simplifier certaines mutations d'état. Cela peut devenir une solution intéressante pour un projet avec des états particulièrement sophistiqués. (Ce qui sera rarement notre cas)

😵 Changer une propriété dans un objet

Disons qu'on a l'état suivant, qui contient un objet :

const [npc, setNpc] = useState(new Npc("Khajiit", "Khajiit has wares... if you have coin.", 176));

Pour modifier la moindre propriété de cet objet, il faudra malheureusement le recréer en entier !

function whateverFunction() : void{
// On veut seulement augmenter son âge de 1 !
setNpc(new Npc(npc.name, npc.dialog, npc.age + 1));
}

On voit qu'on a dû récupérer son ancien name et son ancienne quote en recréant l'objet.

attention

⛔ Encore une fois, il est impossible de modifier un état qui contient un objet comme ceci :

npc.age = npc.age + 1;

(Ça compile, mais appeler la fonction ne changera rien dans la page, visuellement.)

astuce

✨ Il existe un raccourci pour ne pas avoir à remplir les propriétés qu'on ne souhaitait pas changer :

function whateverFunction() : void{
setNpc({
...npc, // On garde tout comme avant
age : npc.age + 1 // sauf l'âge !
});
}

Remarquez l'usage de ...npc et l'usage d'accolades { ... }.

🗄 Changer un tableau

Que ce soit pour ajouter, retirer ou modifier une donnée dans un tableau, il faudra être minutieux également.

Disons qu'on a l'état suivant, qui contient un tableau 🥔🍟 :

const [ingredients, setIngredients] = useState(["patate", "huile d'olive", "sel"]);
attention

🚫 Malheureusement, il est impossible de faire les manipulations suivantes sur un état :

function whateverFunction() : void{
ingredients.push("fécule de maïs"); // Code pour ajouter une donnée à la fin
ingredients.pop(); // Code pour retirer la dernière donnée
ingredients.splice(0, 1); // Code pour retirer la première donnée
ingredients[0] = "courgette"; // Code pour modifier la première donnée
}

(En fait, ça compile, mais ça ne fonctionnera pas, visuellement, dans la page)

✅ Alternatives valides

Ajouter une donnée (remarquez ...ingredients et les crochets [ ... ]) :

function whateverFunction() : void {
setIngredients([
...ingredients, // On conserve les données actuelles
"fécule de maïs" // et on ajoute celle-ci à la fin !
]);
}

Retirer une donnée :

function whateverFunction() : void {
// On garde tout... sauf la dernière donnée
setIngredients(ingredients.slice(0, ingredients.length - 1));

// On garde tout... sauf la première donnée
setIngredients(ingredients.slice(1, ingredients.length));

// On garde tout... sauf une donnée à un index quelconque (i est l'index) 😵
let i : number = 5;
setIngredients(ingredients.slice(0, i).concat(ingredients.slice(i + 1, ingredients.length)));
}

Modifier une donnée :

function whateverFunction() : void {
let ingredientsCopy : string[] = [...ingredients]; // Copier le tableau dans une variable locale
ingredientsCopy[0] = "courgette"; // Modifier une valeur

setIngredients(ingredientsCopy); // Modifier l'état
}

Modifier un tableau d'objets :

const [npcs, setNpcs] = useState([
new Npc("Ali", "Allo !", 19),
new Npc("Bob", "Bonjour !", 23),
new Npc("Camilo", "Ça va ?", 18)
]);

function whateverFunction(): void {
let npcsCopy : Npc[] = [...npcs]; // Copier le tableau dans une variable locale
npcsCopy[1].age++; // Modifier un objet

setNpcs(npcsCopy); // Modifier l'état
}

🗄 Afficher une liste

👶 Exemple simple

Disons qu'on souhaite afficher une liste de longueur arbitraire dans la page Web de manière élégante ...

const [ingredients, setIngredients] = useState(["patate", "huile d'olive", "sel"]);

Nous allons utiliser la fonction map() dans le HTML, qui sert à répéter une opération pour chaque élément du tableau :

return (
<div className="m-2">
<div className="text-2xl">Ingrédients</div>
<ul className="list-disc mx-4">
{ingredients.map(
(i) => <li key={i}>{i}</li>
)}
</ul>
</div>
);
Affichage d&#39;une liste

Le morceau de code ci-dessous permet de créer un élément <li> pour chaque donnée du tableau ingredients :

{ingredients.map(
(i) => <li key={i}>{i}</li>
)}
  • i représente chaque donnée du tableau, séquentiellement. ("patate", puis "huile d'olive", puis "sel")
  • <li key={i}>{i}</li> est l'élément HTML qui sera répété pour chaque donnée du tableau.
  • key est un attribut obligatoire qui doit être rempli avec une donnée unique pour chaque élément du tableau. Comme c'est un simple tableau de string... chaque string lui-même servira de key unique.
astuce

🤔 L'attribut key est obligatoire car React s'en sert pour mettre à jour le rendu HTML lorsqu'un élément du tableau change.

⚠ Si jamais votre liste ne possède aucune key unique pouvant identifier chaque donnée, vous pouvez utiliser key={index}, qui est moins performant, mais qui dépanne :

const [ages, setAges] = useState([17, 18, 17, 19, 20, 18]); // Certaines valeurs se répètent ...
<ul className="list-disc mx-4">
{ages.map(
(i, index) => <li key={index}>{index} - {i}</li>
// i contient chaque donnée.
// index contient chaque ... index ! (0, 1, 2, etc.)
)}
</ul>
Affichage d&#39;une liste

🧩 Exemple sophistiqué

Voici un exemple avec un objet personnalisé :

export class Npc{

constructor(
public name : string,
public quote : string,
public age : number | null
){}

}
Affichage d&#39;une liste sophistiqué

✔ Affichage conditionnel

👶 Exemple simple

// Âge de l'utilisateur
const [userAge, setUserAge] = useState(18);

function displayButtons(){

// Boutons pour les 18+
if(userAge >= 18){
return <div>
<button className="bg-blue-500 text-white py-2 px-4 rounded-sm font-bold mr-2">Acheter des cigarettes 🚬</button>
<button className="bg-blue-500 text-white py-2 px-4 rounded-sm font-bold">Acheter des briques 🧱</button>
</div>;
}
// Boutons pour les 17-
else{
return <button className="bg-blue-500 text-white py-2 px-4 rounded-sm font-bold">Acheter des briques 🧱</button>;
}
}

// Rendu HTML
return (
<div className="m-2">
{displayButtons()}
</div>
);

Bien entendu, dans ce cas, puisque userAge est supérieur ou égal à 18, les deux boutons s'afficheront.

Affichage condition
astuce

Il est également possible d'utiliser un stratagème syntaxique suivant la forme booléen && expression pour éviter d'avoir à créer une fonction :

return (
<div className="m-2">
{
userAge >= 18 && <button className="bg-blue-500 text-white py-2 px-4 rounded-sm font-bold">Acheter des cigarettes 🚬</button>
}
<button className="bg-blue-500 text-white py-2 px-4 rounded-sm font-bold">Acheter des briques 🧱</button>
</div>
);

Le résultat est identique :

Affichage condition

On peut utiliser une condition ternaire dans certaines situations (Quand on veut afficher une chose OU l'autre) :

return (
<div className="m-2">
<button className="bg-blue-500 text-white py-2 px-4 rounded-sm font-bold">
{
userAge >= 18 ? <span>Acheter des cigarettes 🚬</span> : <span>Acheter des briques 🧱</span>
}
</button>
</div>
);

🧩 Exemple sophistiqué

Voici un exemple avec des objets personnalisés :

export class Youtuber{

constructor(
public name : string,
public content : string,
public age : number | null
){}

}
Affichage conditionnel avec liste
info

L'opérateur ?? permet de choisir une valeur différente si jamais la propriété y.age est null. Ça a permis d'afficher ??? pour SussyBaka69 puisque son âge est null

astuce

Dans la fonction isSussy(), on aurait également pu utiliser une condition ternaire, bien entendu. C'est pas mal plus léger :

function isSussy(content : string){
return content == "ASMR" || content == "NSFW" ? <span>😳</span> : "";
}

Il est même possible d'intégrer directement la condition ternaire dans le rendu HTML (plus besoin de la fonction isSussy() !) :

<li key={y.name}>{y.name} ({y.age ?? '???'} ans) fait des vidéos sur le thème « {y.content} » {y.content == "ASMR" || y.content == "NSFW" ? <span>😳</span> : ""}</li>

🖱 Événements

React nous simplifie la vie lorsqu'on souhaite intégrer des écouteurs d'événements à nos pages Web.

👶 Exemple simple

Par exemple, disons qu'on veut un bouton qui augmente un compteur de 1 et qu'on veut afficher ce compteur...

D'abord, créons un état pour stocker la valeur du compteur et une fonction qui permet d'incrémenter cet état.

const [x, setX] = useState(0);

function incrementX(){
setX(x + 1);
}

Ensuite, dans le rendu HTML, on ajoute un écouteur d'événements de type onClick sur un bouton pour appeler la fonction incrementX() à chaque fois que le bouton est cliqué.

return (
<div className="m-2">
<div>{x}</div>
<button className="bg-blue-500 text-white py-2 px-4 rounded-sm font-bold" onClick={incrementX}>Incrémenter X</button>
</div>
);

Résultat : Comme on affiche {x} dans la page Web, on peut voir la valeur de x évoluer dynamiquement à chaque fois qu'on appuie sur le bouton.

Événement clic
attention

⛔ Dans un écouteur d'événements, il n'est pas possible d'appeler une fonction avec paramètre(s) comme ceci :

<button onClick={maFonction("param1", "param2")}>Clique-moi</button>

✅ Pour y arriver, il faut utiliser une fonction anonyme qui permet de contourner cette contrainte :

<button onClick={() => maFonction("param1", "param2")}>Clique-moi</button>

(Donc on a simplement ajouté () => devant l'appel de la fonction)

🧩 Exemple sophistiqué

Bien entendu, onClick n'est pas le seul type d'événement. N'hésitez pas à consulter cette liste.

info

Si vous ne connaissez pas déjà les template string, voici un exemple :

let nom = "Simone";
let moment = "jour";

// phrase contient "Hey ! Bonjour Simone"
let phrase = `Hey ! ${moment == "jour" ? "Bonjour" : "Bonsoir"} ${nom}`;

Notez qu'un template string est entouré d'accents graves `

Ci-dessous, nous avons trois boutons qui peuvent être survolés pour changer la classe d'un texte. Remarquez que l'état textColor est utilisé comme classe pour un élément HTML à l'aide d'un template string.

const [textColor, setTextColor] = useState("blueText");

function changeTextColor(color : string){
setTextColor(color);
}

// Rendu HTML
return (
<div className="m-2">
<div className={`${textColor} mb-1`}>Ce texte peut changer de couleur</div>
<button className="btn btn-blue mr-2" onMouseOver={() => changeTextColor('blueText')}>Bleu</button>
<button className="btn btn-red mr-2" onMouseOver={() => changeTextColor('redText')}>Rouge</button>
<button className="btn btn-yellow" onMouseOver={() => changeTextColor('yellowText')}>Jaune</button>
</div>
);
Événement de survol

CSS utilisé pour le texte :

.blueText{
color:cornflowerblue;
}

.redText{
color:crimson;
}

.yellowText{
color:goldenrod;
}

📝 Formulaires

♊ Two-way binding

Il est possible d'associer un état du composant avec la valeur d'un élément <input> du HTML. C'est-à-dire que si la valeur de l'état est modifiée dans le code TypeScript, la valeur de l'élément <input> sera modifiée également. De plus, si l'utilisateur modifie la valeur dans l'élément <input>, la valeur de l'état changera également. (Bref, les deux valeurs sont liées)

On doit d'abord créer un état dans le composant.

const [favoriteWord, setFavoriteWord] = useState("");

Puis, on prépare un <input> HTML :

<input type="text" className="textInput" name="favoriteWord" placeholder="Mot préféré" />

Pour le moment, l'état et l'input HTML ne sont pas liés du tout. La prochaine étape concrétisera le two way binding.

Ajoutez l'événement onChange suivant et l'attribut value suivant :

<input value={favoriteWord} onChange={(e) => setFavoriteWord(e.target.value)} type="text" className="textInput" name="favoriteWord" placeholder="Mot préféré" />

Désormais, l'état et l'input sont bel et bien liés : leur valeur sera toujours synchronisée, car dès que l'input change, l'état changera aussi. On peut tester visuellement cette synchronisation en modifiant le HTML comme ceci :

const [favoriteWord, setFavoriteWord] = useState("");

return (
<div className="m-2">
<input value={favoriteWord} onChange={(e) => setFavoriteWord(e.target.value)} type="text" className="textInput" name="favoriteWord" placeholder="Mot préféré" />
<div>{favoriteWord}</div>
</div>
);

Bien entendu, l'élément <div> contiendra toujours le même texte que l'input.

Two-way binding
Two-way binding

Grâce à ce mécanisme, nous aurons facilement accès à la donnée fournie par l'utilisateur dans l'état favoriteWord.

attention

Parfois, lorsqu'on implémente le two-way binding, Next.js lève une erreur 🐞 au sujet d'un controlled input. Si vous avez bel et bien fait les deux étapes du two-way binding (value et onChange), réactualisez la page Web et l'erreur devrait disparaître.

🧩 Exemple de formulaire plus sophistiqué

Dans cet exemple, nous utiliserons une classe Item et nous permettrons à l'utilisateur d'ajouter plusieurs items à son inventaire à l'aide d'un formulaire.

Voici la classe Item qui sera utilisée :

export class Item{

constructor(
public name : string,
public dateAdded : Date,
public quantity : number,
public isBroken : boolean
){}

}

Dans le composant, on a un état prêt à accueillir la donnée fournie par l'utilisateur pour chacun des quatre champs et une liste qui contiendra tous les items créés par l'utilisateur :

const [name, setName] = useState<string>("");
const [dateAdded, setDateAdded] = useState<string>("");
const [quantity, setQuantity] = useState<number>(1);
const [isBroken, setIsBroken] = useState<boolean>(false);

const[items, setItems] = useState<Item[]>([]); // Tableau d'items vide
astuce

useState<T>() permet de spécifier le type ( T ) d'un état. Dans ce cas-ci c'est uniquement essentiel pour items puisque le tableau est vide initialement et ne permet pas de témoigner du type d'objets qu'il contiendra. Item[] signifie tableau d'Item.

Dans le HTML, on crée quatre <input> adaptés à chaque propriété et on utilise onChange et value pour faire la liaison avec les états. Remarquez que les valeurs par défaut choisies pour les variables de classe (nom "", date "", quantité à 1 et item pas brisé) se reflètent dans l'état initial du formulaire.

Nom : <input type="text" value={name} onChange={(e) => setName(e.target.value)} className="textInput" />
Date d'ajout : <input type="date" value={dateAdded} onChange={(e) => setDateAdded(e.target.value)} className="textInput" />
Quantité : <input type="number" value={quantity} onChange={(e) => setQuantity(+e.target.value)} className="textInput" />
Brisé ? : <input type="checkbox" checked={isBroken} onChange={(e) => setIsBroken(e.target.checked)} className="textInput" />
info

Ci-dessus, l'opérateur + dans l'expression setQuantity(+e.target.value) permet de convertir le string du champ en number.

Ensuite, sous le formulaire, on a besoin d'un bouton qui permettra à l'utilisateur de créer son item pour l'ajouter à la liste d'items :

<button className="btn btn-blue" onClick={addItem}>Ajouter</button>

Nous allons devoir coder la fonction addItem() dans le composant. Elle va simplement créer un new Item(...) à l'aide des données fournies par l'utilisateur. Ce nouvel item sera ajouté à la liste dans l'état items :

function addItem(){
setItems([
...items,
new Item(name, new Date(dateAdded), quantity, isBroken)
]);
}

Avant de tester, on prépare un affichage quelque part dans le HTML pour la liste d'items :

<ul className="list-disc ml-6 mb-5">
{items.map((i) => <li key={i.name}>{i.quantity} x {i.name} (Obtenu le {i.dateAdded.toLocaleDateString()}) ({i.isBroken ? 'Brisé' : 'Intact'})</li>)}
</ul>

Voici le résultat final dans la page Web après avoir créé 3 items grâce au formulaire :

Affichage d&#39;une liste avec formulaire

Code complet :

const [name, setName] = useState<string>("");
const [dateAdded, setDateAdded] = useState<string>("");
const [quantity, setQuantity] = useState<number>(1);
const [isBroken, setIsBroken] = useState<boolean>(false);

const[items, setItems] = useState<Item[]>([]); // Tableau d'items vide

function addItem(){
setItems([
...items,
new Item(name, new Date(dateAdded), quantity, isBroken)
]);
}

return (
<div className="m-2">
<div className="text-2xl">Items :</div>
<ul className="list-disc ml-6 mb-5">
{items.map((i) => <li key={i.name}>{i.quantity} x {i.name} (Obtenu le {i.dateAdded.toLocaleDateString()}) ({i.isBroken ? 'Brisé' : 'Intact'})</li>)}
</ul>
<div className="text-xl">Créer un item : </div>
Nom : <input type="text" value={name} onChange={(e) => setName(e.target.value)} className="textInput" />
Date d'ajout : <input type="date" value={dateAdded} onChange={(e) => setDateAdded(e.target.value)} className="textInput" />
Quantité : <input type="number" value={quantity} onChange={(e) => setQuantity(+e.target.value)} className="textInput" />
Brisé ? : <input type="checkbox" checked={isBroken} onChange={(e) => setIsBroken(e.target.checked)} className="textInput" />
<button className="bg-blue-500 text-white py-2 px-4 rounded-sm font-bold" onClick={addItem}>Ajouter</button>
</div>
);