Cours JavaScript et React · Module 12: React et TypeScript

Projet principal : tableau de bord cosmique typé

6 min de lecture
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 :

  1. Panneau principal - affiche les statistiques des missions
  2. Liste des objets cosmiques - avec filtrage par type (planètes, étoiles, astéroïdes)
  3. Formulaire d'ajout - avec typage complet des événements
  4. 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 ?

Articles utiles