Cours JavaScript et React · Module 12: React et TypeScript
Projet principal : tableau de bord cosmique typé
Dans cette leçon5
Le centre de contrôle de mission reçoit des données de toute la galaxie : planètes, étoiles et astéroïdes, chacun avec des champs différents. L'opérateur les filtre, ajoute de nouvelles découvertes et change le thème du pupitre pour le quart de nuit. Un seul type erroné dans un tel système, et l'écran affiche "undefined" au lieu de la température d'une étoile. Il est temps de combiner toutes les connaissances de ce module en un seul projet ! Tu vas construire un tableau de bord cosmique typé qui utilise :
- Les interfaces et les types - pour les données de planètes, de missions et d'équipage
- Des composants typés - avec un typage complet des props
- useState avec génériques - pour la gestion de l'état
- Des événements typés - dans les formulaires et les interactions
- Les unions discriminées - pour les différents types de données
- Le Context API avec TypeScript - pour l'état global
Description du projet
Le tableau de bord cosmique se compose de plusieurs sections :
- Panneau principal - affiche les statistiques des missions
- Liste des objets cosmiques - avec filtrage par type (planètes, étoiles, astéroïdes)
- Formulaire d'ajout - avec typage complet des événements
- Sélecteur de thème - sombre/clair avec le Context API
Mon conseil : commence par les types, pas par le JSX. Une fois la carte des données prête, les composants s'écrivent presque tout seuls, car l'éditeur propose chaque champ.
Architecture des types
La première étape est une interface de base avec les champs communs à tous les objets, plus trois variantes qui l'étendent avec extends. Chaque variante ajoute un discriminant kind au type littéral, exactement comme dans la leçon sur les unions discriminées :
1// Interface de base
2interface SpaceEntity {
3 id: string;
4 name: string;
5 discoveredYear: number;
6}
7
8// Union discriminée - différents types d'objets
9interface PlanetEntity extends SpaceEntity {
10 kind: "planet";
11 radius: number;
12 habitable: boolean;
13}
14
15interface StarEntity extends SpaceEntity {
16 kind: "star";
17 temperature: number;
18 spectralClass: "O" | "B" | "A" | "F" | "G" | "K" | "M";
19}
20
21interface AsteroidEntity extends SpaceEntity {
22 kind: "asteroid";
23 diameter: number;
24 composition: string;
25}Le champ spectralClass n'accepte que les sept vraies classes spectrales stellaires, de la plus chaude, O, à la plus froide, M. On réunit maintenant les variantes en une union et on décrit l'état du tableau de bord :
1type CosmicObject = PlanetEntity | StarEntity | AsteroidEntity;
2
3// État du tableau de bord
4interface DashboardState {
5 objects: CosmicObject[];
6 filter: CosmicObject["kind"] | "all";
7 searchQuery: string;
8}CosmicObject["kind"] extrait de l'union toutes les valeurs du discriminant, soit "planet" | "star" | "asteroid". On y ajoute "all" et le filtre connaît toutes les options autorisées. Quand tu ajoutes une quatrième variante, le filtre se met à jour tout seul - rien à modifier à la main.
Composant DataTable générique
Le tableau doit afficher n'importe quels objets, il est donc générique. L'élément nouveau est l'opérateur keyof T, qui renvoie une union des noms de champs du type T. Grâce à lui, une colonne ne peut désigner qu'un champ qui existe réellement. T[keyof T] désigne quant à lui le type de la valeur de n'importe lequel de ces champs.
1// Composant de tableau générique avec typage complet
2interface Column<T> {
3 key: keyof T;
4 label: string;
5 render?: (value: T[keyof T], item: T) => React.ReactNode;
6}
7
8interface DataTableProps<T extends { id: string }> {
9 data: T[];
10 columns: Column<T>[];
11 onRowClick?: (item: T) => void;
12}La contrainte T extends { id: string } garantit une clé pour chaque ligne. Le render optionnel permet à une colonne de dessiner sa valeur à sa façon, par exemple avec une unité. Voici le composant lui-même :
1function DataTable<T extends { id: string }>({
2 data, columns, onRowClick
3}: DataTableProps<T>) {
4 return (
5 <table>
6 <thead>
7 <tr>
8 {columns.map(col => (
9 <th key={String(col.key)}>{col.label}</th>
10 ))}
11 </tr>
12 </thead>
13 <tbody>
14 {data.map(item => (
15 <tr key={item.id} onClick={() => onRowClick?.(item)}>
16 {columns.map(col => (
17 <td key={String(col.key)}>
18 {col.render
19 ? col.render(item[col.key], item)
20 : String(item[col.key])}
21 </td>
22 ))}
23 </tr>
24 ))}
25 </tbody>
26 </table>
27 );
28}String(col.key) est nécessaire, car keyof T peut en théorie contenir des symboles, alors que la key de React attend une chaîne ou un nombre. onRowClick?.(item) n'appelle la fonction que si le parent l'a fournie.
Formulaire d'ajout typé
Le formulaire utilise lui aussi une union discriminée : chaque sorte d'objet a des champs différents, et les valeurs des champs texte sont stockées en string, car c'est ce que renvoie e.target.value. La conversion en nombres n'a lieu qu'à la création de l'objet.
1type NewObjectForm =
2 | { kind: "planet"; name: string; radius: string; habitable: boolean }
3 | { kind: "star"; name: string; temperature: string; spectralClass: string }
4 | { kind: "asteroid"; name: string; diameter: string; composition: string };Le composant reçoit un callback onAdd et garde dans l'état la sorte d'objet choisie et le nom. Le gestionnaire de soumission utilise React.SubmitEvent (dans les anciens projets, tu verras ici React.FormEvent, désormais obsolète) :
1function AddObjectForm({ onAdd }: { onAdd: (obj: CosmicObject) => void }) {
2 const [kind, setKind] = useState<CosmicObject["kind"]>("planet");
3 const [name, setName] = useState("");
4
5 const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
6 e.preventDefault();
7 // Crée l'objet approprié en fonction du type sélectionné
8 const id = crypto.randomUUID();
9 const year = new Date().getFullYear();
10
11 // ... logique de création de l'objet
12 };
13
14 return (
15 <form onSubmit={handleSubmit}>
16 <select
17 value={kind}
18 onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
19 setKind(e.target.value as CosmicObject["kind"])
20 }
21 >
22 <option value="planet">Planet</option>
23 <option value="star">Star</option>
24 <option value="asteroid">Asteroid</option>
25 </select>
26 <input value={name} onChange={e => setName(e.target.value)} />
27 <button type="submit">Add</button>
28 </form>
29 );
30}crypto.randomUUID() est une fonction intégrée du navigateur qui génère un identifiant unique - elle fonctionne sur les pages HTTPS et sur localhost. Nous n'avons pas donné de type d'événement à l'input, car pour une fonction écrite directement dans l'attribut, TypeScript le déduit. Tu écriras la logique de création manquante en exercice : un switch sur kind et assertNever dans la branche default.
Le projet complet
Ci-dessous, tu trouveras l'implémentation complète du tableau de bord cosmique avec un typage TypeScript complet. Analyse le code, modifie-le et expérimente avec les types ! Essaie d'ajouter une quatrième sorte d'objet, par exemple une comète, et observe combien d'endroits le compilateur te signale.
Code de cette leçon : App.tsx
1import React, { createContext, useContext, useReducer, useState, useRef } from 'react';
2
3// ===== TYPES DE DONNÉES =====
4interface SpaceEntity {
5 id: string;
6 name: string;
7 discoveredYear: number;
8}
9
10interface PlanetEntity extends SpaceEntity {
11 kind: "planet";
12 radius: number;
13 habitable: boolean;
14}
15
16interface StarEntity extends SpaceEntity {
17 kind: "star";
18 temperature: number;
19 spectralClass: "O" | "B" | "A" | "F" | "G" | "K" | "M";
20}
21
22interface AsteroidEntity extends SpaceEntity {
23 kind: "asteroid";
24 diameter: number;
25 composition: string;
26}
27
28type CosmicObject = PlanetEntity | StarEntity | AsteroidEntity;
29
30// ===== ÉTAT ET ACTIONS =====
31interface DashboardState {
32 objects: CosmicObject[];
33 filter: CosmicObject["kind"] | "all";
34 searchQuery: string;
35 theme: "dark" | "light";
36}
37
38type DashboardAction =
39 | { type: "ADD_OBJECT"; payload: CosmicObject }
40 | { type: "REMOVE_OBJECT"; payload: string }
41 | { type: "SET_FILTER"; payload: DashboardState["filter"] }
42 | { type: "SET_SEARCH"; payload: string }
43 | { type: "TOGGLE_THEME" };
44
45function dashboardReducer(state: DashboardState, action: DashboardAction): DashboardState {
46 switch (action.type) {
47 case "ADD_OBJECT":
48 return { ...state, objects: [...state.objects, action.payload] };
49 case "REMOVE_OBJECT":
50 return { ...state, objects: state.objects.filter(o => o.id !== action.payload) };
51 case "SET_FILTER":
52 return { ...state, filter: action.payload };
53 case "SET_SEARCH":
54 return { ...state, searchQuery: action.payload };
55 case "TOGGLE_THEME":
56 return { ...state, theme: state.theme === "dark" ? "light" : "dark" };
57 default:
58 return state;
59 }
60}
61
62// ===== CONTEXT =====
63interface DashboardContextType {
64 state: DashboardState;
65 dispatch: React.Dispatch<DashboardAction>;
66}
67
68const DashboardContext = createContext<DashboardContextType | undefined>(undefined);
69
70function useDashboard(): DashboardContextType {
71 const ctx = useContext(DashboardContext);
72 if (!ctx) throw new Error("useDashboard nécessite DashboardProvider");
73 return ctx;
74}
75
76// ===== COMPOSANT GÉNÉRIQUE =====
77interface HasId { id: string; }
78
79function DataList<T extends HasId>({
80 items, renderItem, emptyMsg = "Aucune donnée"
81}: {
82 items: T[];
83 renderItem: (item: T) => React.ReactNode;
84 emptyMsg?: string;
85}) {
86 if (items.length === 0) return <p className="empty">{emptyMsg}</p>;
87 return (
88 <div className="data-list">
89 {items.map(item => <div key={item.id} className="data-item">{renderItem(item)}</div>)}
90 </div>
91 );
92}
93
94// ===== COMPOSANT CARTE =====
95function ObjectCard({ obj, onRemove }: { obj: CosmicObject; onRemove: (id: string) => void }) {
96 const icons = { planet: "P", star: "S", asteroid: "A" };
97 const colors = { planet: "#22c55e", star: "#f59e0b", asteroid: "#ef4444" };
98
99 const renderDetails = (): React.ReactNode => {
100 switch (obj.kind) {
101 case "planet":
102 return <><p>Rayon : {obj.radius} km</p><p>{obj.habitable ? "Habitable" : "Inhabitable"}</p></>;
103 case "star":
104 return <><p>Temp : {obj.temperature}K</p><p>Classe : {obj.spectralClass}</p></>;
105 case "asteroid":
106 return <><p>Diamètre : {obj.diameter} km</p><p>Composition : {obj.composition}</p></>;
107 }
108 };
109
110 return (
111 <div className="object-card" style={{ borderLeftColor: colors[obj.kind] }}>
112 <div className="card-top">
113 <span className="obj-icon" style={{ background: colors[obj.kind] }}>{icons[obj.kind]}</span>
114 <div>
115 <h3>{obj.name}</h3>
116 <span className="year">Découvert : {obj.discoveredYear}</span>
117 </div>
118 <button className="remove-btn" onClick={() => onRemove(obj.id)}>X</button>
119 </div>
120 {renderDetails()}
121 </div>
122 );
123}
124
125// ===== FORMULAIRE =====
126function AddForm() {
127 const { dispatch } = useDashboard();
128 const [kind, setKind] = useState<CosmicObject["kind"]>("planet");
129 const [name, setName] = useState("");
130 const nameRef = useRef<HTMLInputElement>(null);
131
132 const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
133 e.preventDefault();
134 if (!name.trim()) return;
135 const base = { id: Date.now().toString(), name, discoveredYear: 2026 };
136
137 let obj: CosmicObject;
138 switch (kind) {
139 case "planet":
140 obj = { ...base, kind: "planet", radius: 6371, habitable: false };
141 break;
142 case "star":
143 obj = { ...base, kind: "star", temperature: 5778, spectralClass: "G" };
144 break;
145 case "asteroid":
146 obj = { ...base, kind: "asteroid", diameter: 100, composition: "roche" };
147 break;
148 }
149 dispatch({ type: "ADD_OBJECT", payload: obj });
150 setName("");
151 nameRef.current?.focus();
152 };
153
154 return (
155 <form onSubmit={handleSubmit} className="add-form">
156 <select value={kind} onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
157 setKind(e.target.value as CosmicObject["kind"])
158 }>
159 <option value="planet">Planète</option>
160 <option value="star">Étoile</option>
161 <option value="asteroid">Astéroïde</option>
162 </select>
163 <input ref={nameRef} value={name} onChange={e => setName(e.target.value)} placeholder="Nom de l'objet" />
164 <button type="submit">Ajouter</button>
165 </form>
166 );
167}
168
169// ===== STATISTIQUES =====
170function Stats() {
171 const { state } = useDashboard();
172 const total = state.objects.length;
173 const planets = state.objects.filter(o => o.kind === "planet").length;
174 const stars = state.objects.filter(o => o.kind === "star").length;
175 const asteroids = state.objects.filter(o => o.kind === "asteroid").length;
176
177 return (
178 <div className="stats">
179 <div className="stat"><span className="stat-num">{total}</span><span>Total</span></div>
180 <div className="stat"><span className="stat-num" style={{color:"#22c55e"}}>{planets}</span><span>Planètes</span></div>
181 <div className="stat"><span className="stat-num" style={{color:"#f59e0b"}}>{stars}</span><span>Étoiles</span></div>
182 <div className="stat"><span className="stat-num" style={{color:"#ef4444"}}>{asteroids}</span><span>Astéroïdes</span></div>
183 </div>
184 );
185}
186
187// ===== APPLICATION PRINCIPALE =====
188const initialState: DashboardState = {
189 objects: [
190 { id: "1", kind: "planet", name: "Kepler-442b", discoveredYear: 2015, radius: 8200, habitable: true },
191 { id: "2", kind: "star", name: "Betelgeuse", discoveredYear: 1603, temperature: 3500, spectralClass: "M" },
192 { id: "3", kind: "asteroid", name: "Apophis", discoveredYear: 2004, diameter: 370, composition: "fer, nickel" },
193 { id: "4", kind: "planet", name: "Proxima b", discoveredYear: 2016, radius: 7100, habitable: true },
194 { id: "5", kind: "star", name: "Sirius", discoveredYear: -200, temperature: 9940, spectralClass: "A" },
195 ],
196 filter: "all",
197 searchQuery: "",
198 theme: "dark"
199};
200
201function Dashboard() {
202 const { state, dispatch } = useDashboard();
203
204 const filtered = state.objects
205 .filter(o => state.filter === "all" || o.kind === state.filter)
206 .filter(o => o.name.toLowerCase().includes(state.searchQuery.toLowerCase()));
207
208 return (
209 <>
210 <header className="header">
211 <h1>Tableau de bord cosmique</h1>
212 <button onClick={() => dispatch({ type: "TOGGLE_THEME" })}>
213 {state.theme === "dark" ? "Clair" : "Sombre"}
214 </button>
215 </header>
216
217 <Stats />
218 <AddForm />
219
220 <div className="toolbar">
221 <input
222 className="search"
223 value={state.searchQuery}
224 onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
225 dispatch({ type: "SET_SEARCH", payload: e.target.value })
226 }
227 placeholder="Rechercher..."
228 />
229 <div className="filter-btns">
230 {(["all","planet","star","asteroid"] as const).map(f => (
231 <button key={f}
232 className={"fbtn " + (state.filter === f ? "active" : "")}
233 onClick={() => dispatch({ type: "SET_FILTER", payload: f })}
234 >
235 {f === "all" ? "Toutes" : f === "planet" ? "Planètes" : f === "star" ? "Étoiles" : "Astéroïdes"}
236 </button>
237 ))}
238 </div>
239 </div>
240
241 <DataList<CosmicObject>
242 items={filtered}
243 renderItem={(obj) => (
244 <ObjectCard obj={obj} onRemove={(id) => dispatch({ type: "REMOVE_OBJECT", payload: id })} />
245 )}
246 emptyMsg="Aucun objet trouvé"
247 />
248 </>
249 );
250}
251
252function App() {
253 const [state, dispatch] = useReducer(dashboardReducer, initialState);
254 return (
255 <DashboardContext.Provider value={{ state, dispatch }}>
256 <div className={"app " + state.theme}>
257 <Dashboard />
258 </div>
259 </DashboardContext.Provider>
260 );
261}
262
263export default App;Après ce projet, tu es prêt pour les applications typées des mondes suivants, où TypeScript t'accompagnera dans Next.js et côté serveur.
Retiens : un tableau de bord bien typé est un centre de contrôle de mission où chaque indicateur affiche exactement ce qu'il promet.
Tu as repéré une erreur dans cette leçon ?