Cours JavaScript et React · Module 12: React et TypeScript
Composants génériques - systèmes de navigation universels
Dans cette leçon5
Tu as un composant de liste de planètes. Une semaine plus tard, il te faut une liste d'étoiles, puis de membres d'équipage, puis de vaisseaux. Tu copies le composant une quatrième fois en changeant seulement le type - et désormais chaque bug doit être corrigé à quatre endroits. Imagine plutôt un système de navigation universel sur un vaisseau spatial, capable d'afficher des données sur des planètes, des étoiles, des vaisseaux - tout ce que tu lui donnes. En TypeScript, on crée de tels composants "universels" grâce aux génériques.
Que sont les génériques ?
Les génériques sont des paramètres de type - ils permettent de créer des composants et des fonctions qui fonctionnent avec différents types de données tout en gardant un contrôle total des types. Tu écris un paramètre de type entre chevrons, généralement une seule lettre majuscule T (pour "type"). C'est un emplacement pour un type qui ne sera fourni qu'au moment de l'utilisation - comme une variable, mais pour les types.
L'exemple utilise les interfaces Planet et Star de la leçon sur les props, et planets et stars sont des tableaux de tels objets :
1// Sans génériques - il faut des fonctions séparées
2function getFirstPlanet(items: Planet[]): Planet {
3 return items[0];
4}
5function getFirstStar(items: Star[]): Star {
6 return items[0];
7}Les deux fonctions font exactement la même chose et ne diffèrent que par le type. Un générique les réunit en une seule :
1// Avec génériques - une seule fonction pour tous les types
2function getFirst<T>(items: T[]): T {
3 return items[0];
4}
5
6const planet = getFirst<Planet>(planets); // retourne Planet
7const star = getFirst<Star>(stars); // retourne Star
8const name = getFirst<string>(["a", "b"]); // retourne stringLis getFirst<T>(items: T[]): T ainsi : "pour n'importe quel type T, prends un tableau de T et renvoie un T". Le type ne se perd pas en route - planet a le type Planet, donc l'éditeur propose ses champs. En pratique, tu peux omettre les chevrons à l'appel, car TypeScript déduit T de l'argument : getFirst(planets) donne le même résultat.
Composant de liste générique
L'une des utilisations les plus courantes des génériques en React est un composant de liste universel. La liste a besoin d'une garantie : chaque élément doit avoir un id, car React exige une key unique. On écrit cette garantie avec extends - T extends HasId signifie "T peut être n'importe quoi, du moment qu'il a un champ id". C'est ce qu'on appelle une contrainte.
1// Chaque élément doit avoir un id à utiliser comme key
2interface HasId {
3 id: number | string;
4}
5
6interface ListProps<T extends HasId> {
7 items: T[];
8 renderItem: (item: T) => React.ReactNode;
9 emptyMessage?: string;
10}La prop renderItem est ce qu'on appelle une render prop : une fonction que le parent passe pour décider comment dessiner un élément. La liste elle-même ne sait rien des planètes - elle sait seulement les disposer :
1function List<T extends HasId>({ items, renderItem, emptyMessage = "No data" }: ListProps<T>) {
2 if (items.length === 0) {
3 return <p>{emptyMessage}</p>;
4 }
5
6 return (
7 <ul>
8 {items.map(item => (
9 <li key={item.id}>{renderItem(item)}</li>
10 ))}
11 </ul>
12 );
13}Grâce à la contrainte, item.id est autorisé dans key. Si tu essayais de lire item.name, TypeScript protesterait - la liste n'a aucune garantie que ce champ existe. Note technique : dans un fichier .tsx, une fonction fléchée générique nécessite la notation <T,> avec une virgule, pour que le compilateur ne la confonde pas avec une balise JSX. Une déclaration function n'a pas ce problème.
Utiliser un composant générique
Préparons deux sortes de données différentes. Les planètes ont un id numérique, les membres d'équipage un id textuel, et les deux satisfont HasId :
1interface Planet {
2 id: number;
3 name: string;
4 type: string;
5}
6
7interface CrewMember {
8 id: string;
9 name: string;
10 rank: string;
11}Le même composant List affiche maintenant les deux collections. <List<Planet> passe le type explicitement, même si TypeScript le déduirait de items :
1function Dashboard() {
2 const planets: Planet[] = [
3 { id: 1, name: "Mars", type: "rocky" },
4 { id: 2, name: "Jupiter", type: "gas giant" }
5 ];
6
7 const crew: CrewMember[] = [
8 { id: "c1", name: "Nova", rank: "Captain" },
9 { id: "c2", name: "Ra", rank: "Navigator" }
10 ];
11
12 return (
13 <div>
14 <h2>Planets</h2>
15 <List<Planet>
16 items={planets}
17 renderItem={(planet) => <span>{planet.name} ({planet.type})</span>}
18 />
19
20 <h2>Crew</h2>
21 <List<CrewMember>
22 items={crew}
23 renderItem={(member) => <span>{member.name} - {member.rank}</span>}
24 />
25 </div>
26 );
27}Dans renderItem, le paramètre planet a le type Planet et member le type CrewMember, sans aucune annotation. Le composant List n'a pas changé d'une seule ligne.
Composant Select générique
Le même modèle fonctionne pour un champ de sélection. La contrainte exige les champs value et label, et onChange renvoie au parent l'objet sélectionné entier, en conservant son type exact. Le gestionnaire de changement utilise ChangeEvent de la leçon précédente :
1interface SelectOption {
2 value: string;
3 label: string;
4}
5
6interface SelectProps<T extends SelectOption> {
7 options: T[];
8 value: string;
9 onChange: (option: T) => void;
10 placeholder?: string;
11}
12
13function Select<T extends SelectOption>({
14 options, value, onChange, placeholder
15}: SelectProps<T>) {
16 const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
17 const selected = options.find(opt => opt.value === e.target.value);
18 if (selected) onChange(selected);
19 };
20
21 return (
22 <select value={value} onChange={handleChange}>
23 {placeholder && <option value="">{placeholder}</option>}
24 {options.map(opt => (
25 <option key={opt.value} value={opt.value}>
26 {opt.label}
27 </option>
28 ))}
29 </select>
30 );
31}La méthode find peut renvoyer undefined, donc if (selected) est indispensable - TypeScript ne te laissera pas passer un éventuel undefined à onChange.
Contraintes des génériques (constraints)
Le mot-clé extends permet de restreindre les types acceptés. La contrainte peut être un type écrit sur place ou une interface nommée :
1// T doit avoir un champ 'name'
2function displayName<T extends { name: string }>(item: T): string {
3 return item.name;
4}
5
6// T doit implémenter l'interface Sortable
7interface Sortable {
8 sortOrder: number;
9}
10
11function sortItems<T extends Sortable>(items: T[]): T[] {
12 return [...items].sort((a, b) => a.sortOrder - b.sortOrder);
13}Une contrainte est un minimum, pas une forme exacte : l'objet peut avoir des champs supplémentaires, et ils sont conservés dans le type du résultat. [...items] crée une copie, donc le tri ne modifie pas le tableau d'origine. Mon conseil : n'utilise un générique que lorsque le même composant gère vraiment deux types de données différents - avant cela, une simple interface suffit. Dans le projet final, tu construiras un tableau de données générique.
Code de cette leçon : App.tsx
1import React, { useState } from 'react';
2
3// Contrainte générique - chaque élément doit avoir un id
4interface HasId {
5 id: number | string;
6}
7
8// Composant liste générique
9interface ListProps<T extends HasId> {
10 items: T[];
11 renderItem: (item: T) => React.ReactNode;
12 title: string;
13 emptyMessage?: string;
14}
15
16function List<T extends HasId>({ items, renderItem, title, emptyMessage = "Aucun élément" }: ListProps<T>) {
17 return (
18 <div className="list-container">
19 <h2>{title} ({items.length})</h2>
20 {items.length === 0 ? (
21 <p className="empty">{emptyMessage}</p>
22 ) : (
23 <ul className="list">
24 {items.map(item => (
25 <li key={item.id} className="list-item">{renderItem(item)}</li>
26 ))}
27 </ul>
28 )}
29 </div>
30 );
31}
32
33// Différents types de données
34interface Planet {
35 id: number;
36 name: string;
37 type: string;
38 habitable: boolean;
39}
40
41interface CrewMember {
42 id: string;
43 name: string;
44 rank: string;
45 specialization: string;
46}
47
48interface Mission {
49 id: number;
50 codeName: string;
51 status: "planned" | "active" | "completed";
52 destination: string;
53}
54
55function App() {
56 const [planets] = useState<Planet[]>([
57 { id: 1, name: "Kepler-442b", type: "rocheuse", habitable: true },
58 { id: 2, name: "HD 209458 b", type: "gazeuse", habitable: false },
59 { id: 3, name: "Proxima Centauri b", type: "rocheuse", habitable: true },
60 ]);
61
62 const [crew] = useState<CrewMember[]>([
63 { id: "c1", name: "Nova", rank: "Capitaine", specialization: "Navigation" },
64 { id: "c2", name: "Ra", rank: "Navigateur", specialization: "Astrophysique" },
65 { id: "c3", name: "Astro", rank: "Ingénieur", specialization: "Moteurs" },
66 ]);
67
68 const [missions] = useState<Mission[]>([
69 { id: 1, codeName: "DAWN", status: "active", destination: "Mars" },
70 { id: 2, codeName: "HORIZON", status: "planned", destination: "Jupiter" },
71 ]);
72
73 const statusColors = { planned: "#f59e0b", active: "#22c55e", completed: "#6b7280" };
74
75 return (
76 <div className="app">
77 <h1>Composants génériques</h1>
78
79 <List<Planet>
80 title="Planètes"
81 items={planets}
82 renderItem={(planet) => (
83 <div className="item-content">
84 <strong>{planet.name}</strong>
85 <span className="detail">{planet.type}</span>
86 <span className={"badge " + (planet.habitable ? "green" : "red")}>
87 {planet.habitable ? "Habitable" : "Inhabitable"}
88 </span>
89 </div>
90 )}
91 />
92
93 <List<CrewMember>
94 title="Équipage"
95 items={crew}
96 renderItem={(member) => (
97 <div className="item-content">
98 <strong>{member.name}</strong>
99 <span className="detail">{member.rank}</span>
100 <span className="spec">{member.specialization}</span>
101 </div>
102 )}
103 />
104
105 <List<Mission>
106 title="Missions"
107 items={missions}
108 renderItem={(mission) => (
109 <div className="item-content">
110 <strong>{mission.codeName}</strong>
111 <span className="detail">{mission.destination}</span>
112 <span style={{ color: statusColors[mission.status] }}>
113 {mission.status.toUpperCase()}
114 </span>
115 </div>
116 )}
117 />
118 </div>
119 );
120}
121
122export default App;Retiens : un générique est un port d'amarrage universel - chaque vaisseau qui respecte les exigences du port s'y amarre sans reconstruire la station.
Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Que signifie la notation <T> dans la déclaration function getFirst<T>(items: T[]): T ?
2. Que signifie la notation <T extends { id: number }> dans un composant React générique ?
Tâches pratiques dans le jeu
- Tri vertical
Ordonne les étapes de création d'un composant liste générique dans le bon ordre :
- Éditeur de code
Composant Select générique avec contraintes
- Tri horizontal
Ordonne la syntaxe d'une contrainte générique en TypeScript :
- Éditeur de code
Composant générique avec render prop et filtrage